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::program::Program;
19use crate::value_ops;
20
21/// Precise execution position: a container index plus the byte offset of
22/// the next instruction to execute inside that container's bytecode.
23///
24/// A public mirror of the runtime-internal
25/// `story::call_stack::ContainerPosition` — deliberately a distinct type
26/// (issue #3182) rather than that type made `pub`, so the VM's internal
27/// call-frame layout stays free to change without turning into a de facto
28/// compatibility surface. `container_idx` is stable within one linked
29/// [`Program`] (it indexes the same `Containers` table
30/// [`Program::container_bytecode`] reads and the table
31/// `docs/debugger-spec.md` §2.2's `DebugInfo` section addresses
32/// lockstep-by-index); `offset` is a byte offset into that container's
33/// bytecode, not a source location — resolving position to source is a
34/// later workstream (D6/D9, `docs/debugger-spec.md`).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct DebugPosition {
37 /// Index into the linked program's container table.
38 pub container_idx: u32,
39 /// Byte offset into that container's bytecode.
40 pub offset: usize,
41}
42
43/// The result of resolving a [`DebugPosition`] to source via the program's
44/// `DebugInfo` section (D6, `docs/debugger-spec.md` §2.2) — see
45/// [`crate::Program::resolve_debug_position`] (D9, issue #3187). This is
46/// the "program → source" half of the studio's Location protocol
47/// (`docs/studio-shell-spec.md` §6.1).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct DebugSourceLocation {
50 /// Project-root-relative source path, or `None` for the reserved
51 /// synthetic sentinel file (compiler-generated content with no author
52 /// source).
53 pub file: Option<String>,
54 /// Absolute source byte offset within `file`.
55 pub range_start: u32,
56 /// Length in bytes of the source range (`range_start + range_len` is
57 /// the exclusive end).
58 pub range_len: u32,
59}
60
61/// A structured, read-only snapshot of the runtime's current state.
62pub struct DebugSnapshot {
63 /// Execution status: `active` / `waiting_for_choice` / `done` / `ended`.
64 pub status: &'static str,
65 /// Nearest named knot/stitch the cursor is currently in, if resolvable.
66 pub current_location: Option<String>,
67 /// Precise execution position for the active flow: the innermost call
68 /// frame's current `(container_idx, offset)`. `None` when the call
69 /// stack has no frame with an open container (e.g. `Ended`/`Done` with
70 /// nothing left to run) — mirrors `call_stack[0].position` when the
71 /// call stack is non-empty. Also `None` whenever the innermost frame is
72 /// a `CallFrameType::External` frame: those are pushed with an empty
73 /// `container_stack` (there is no bytecode position "inside" a
74 /// deferred external call), and an external frame stays the top frame
75 /// for as long as `has_pending_external()` is true — so a flow parked
76 /// on a deferred external (`StepOutcome::AwaitingExternal`, status
77 /// `active`, nothing exhausted) reports `position: None` here too. The
78 /// call site that invoked the external lives on that frame's own
79 /// `return_address`, or equivalently on the position of the caller
80 /// frame just below it.
81 pub position: Option<DebugPosition>,
82 /// Current turn index.
83 pub turn_index: u32,
84 /// Global variables and their current values (display strings).
85 pub globals: Vec<DebugGlobal>,
86 /// Active call frames, innermost (current) first.
87 pub call_stack: Vec<DebugFrame>,
88 /// Per-knot/stitch visit counts, sorted by path.
89 pub visit_counts: Vec<DebugVisit>,
90 /// EVERY container visit count keyed by `DefinitionId` display string
91 /// (W11/#3304): unlike [`Self::visit_counts`] — which resolves through
92 /// named paths and silently drops anonymous containers — this carries
93 /// the anonymous choice/gather bodies too, so a consumer can join
94 /// against the HIR overlay projection's `def_id` (#3234's identity).
95 /// Sorted by id for determinism.
96 pub visit_ids: Vec<DebugVisitId>,
97 /// Choices currently offered to the player.
98 pub pending_choices: Vec<DebugChoice>,
99 /// Story RNG state.
100 pub rng: DebugRng,
101}
102
103/// A global variable and its current value.
104pub struct DebugGlobal {
105 pub name: String,
106 pub value: String,
107}
108
109/// One call frame, resolved to a knot/stitch path.
110pub struct DebugFrame {
111 /// Frame kind: `root` / `function` / `tunnel` / `thread` / `external` / `eval`.
112 pub kind: &'static str,
113 /// Nearest named container for this frame, if resolvable.
114 pub location: Option<String>,
115 /// This frame's current `(container_idx, offset)` — the next
116 /// instruction that will execute if/when this frame becomes active
117 /// again. `None` for a frame whose container stack is empty (exhausted,
118 /// nothing left to run in it) — which includes every
119 /// `CallFrameType::External` frame: those are pushed with an empty
120 /// `container_stack` (there is no bytecode position "inside" a
121 /// deferred external call), so a frame with `kind == "external"`
122 /// always carries `position: None`. The call site that invoked it
123 /// lives on this frame's own `return_address`, or equivalently on the
124 /// position of the caller frame just below it.
125 pub position: Option<DebugPosition>,
126 /// Number of temporary (local) variables in this frame.
127 pub temps: usize,
128 /// D7 (`docs/debugger-spec.md` §3, #3185): this frame's named locals —
129 /// every declared parameter/`~ temp` slot in the frame's own scope,
130 /// bound to its live value. Additive alongside `temps` (D4's bare
131 /// count stays, unchanged, for any existing consumer). `None` when
132 /// this artifact carries no `DebugInfo` (a release-exported story, or
133 /// one compiled before D6) **or** the frame's container stack is
134 /// empty (nothing left to run in it — e.g. every `external` frame, or
135 /// an exhausted frame — so there is no current position to resolve a
136 /// scope from). A frame with a `DebugInfo`-backed program, a live
137 /// position, but genuinely zero declared locals reports `Some(vec![])`,
138 /// not `None`, so a consumer can tell "no debug info available" (or
139 /// "nothing to resolve from") apart from "this frame really has no
140 /// locals."
141 ///
142 /// Resolved via [`crate::program::Program::scope_debug_locals`] against
143 /// the frame's *current leaf* container, grouping by lexical **scope**
144 /// (`ContainerDef::scope_id`) rather than by whatever containers
145 /// currently happen to be on `container_stack` — deliberately: a call
146 /// frame's `container_stack` can legitimately shrink back to a single
147 /// entry mid-frame (`vm::goto_target`'s "target not already on the
148 /// stack" branch clears and replaces it wholesale, e.g. entering a
149 /// `{? … }` choice-target body from its enclosing knot), which would
150 /// silently drop an enclosing container's own parameters/`~ temp`s
151 /// from view the instant the leaf position moved into a *sibling*
152 /// child container — even though the call frame's `temps` are
153 /// completely unaffected by that navigation (`docs/debugger-spec.md`
154 /// §3: VM temp slots "are allocated per active call frame, not
155 /// lexically nested"). On a slot collision (only possible if a future
156 /// codegen change reuses a slot number across sibling scopes — not
157 /// confirmed to happen today, same spec section) the entry from
158 /// whichever sibling container was iterated last wins; see
159 /// `scope_debug_locals`'s own doc for the full reasoning.
160 pub locals: Option<Vec<DebugLocal>>,
161}
162
163/// One named local variable and its current value (`docs/debugger-spec.md`
164/// §3, D7/#3185).
165#[derive(Debug, Clone)]
166pub struct DebugLocal {
167 /// The VM temp slot this local occupies in the call frame — matches
168 /// `DeclareTemp`/`GetTemp`/`SetTemp`'s `u16` operand.
169 pub slot: u16,
170 pub name: String,
171 pub value: DebugValue,
172 /// [`brink_format::DebugLocalEntry::synthetic`]: a compiler-minted temp
173 /// (#3395's lift-order hoist). The studio hides these rows; they are
174 /// still reported so a consumer that wants every slot can see them.
175 pub synthetic: bool,
176}
177
178/// A structured, read-only view of a runtime [`Value`] for the debugger's
179/// locals panel (`docs/debugger-spec.md` §3, D7/#3185).
180///
181/// **Why structured, not another display string.** [`DebugGlobal::value`]
182/// is a display string (`String`) — that shape predates this ticket and
183/// stays as-is (globals are out of D7's scope, and changing an existing
184/// public field is not "additive"). For locals, a display string is the
185/// wrong shape to repeat: the issue's own bar is that a debugger which can
186/// only show `"[list]"` for a list value is not the target, and a plain
187/// string can't do better than that — a studio locals panel needs to tell
188/// "this is a list with these members" from "this is a string that reads
189/// like a list" to render either one correctly (or let a user expand a
190/// struct's fields, or distinguish a null from an empty string). So D7
191/// exposes the value's *kind* structurally for every kind the runtime
192/// distinguishes that the issue calls out by name (int, float, string,
193/// list, divert target, struct, handle), plus the common `bool`/`null`
194/// cases that cost nothing extra to model — and falls back to the existing
195/// [`NameResolver::format_value`] display string ([`DebugValue::Other`])
196/// for the long tail of kinds this ticket has no author-facing UI need to
197/// special-case yet (closures, arrays, maps, weighted tables, function
198/// refs, variable/temp pointers, fragment refs, vector/matrix/quaternion,
199/// ranges, options, projections). `Other` is not a cop-out for the kinds
200/// the issue named — those all get real variants below.
201#[derive(Debug, Clone)]
202pub enum DebugValue {
203 Int(i32),
204 Float(f32),
205 Bool(bool),
206 Str(String),
207 Null,
208 /// A list value's member names, in list order (unresolvable items are
209 /// skipped, matching `NameResolver::format_value`'s existing behavior).
210 List(Vec<String>),
211 /// A divert-target value, resolved to its author-facing knot/stitch
212 /// path where possible. `None` when the target isn't resolvable to a
213 /// named scope (matches `format_value`'s `"-> ?"` case).
214 DivertTarget(Option<String>),
215 /// A struct (`Value::Record`) value: its declared shape name (if
216 /// resolvable) and its fields, named and recursively structured.
217 Struct {
218 name: Option<String>,
219 fields: Vec<(String, DebugValue)>,
220 },
221 /// A handle value (T1d): its manifest-declared kind name and the
222 /// host-allocated token id.
223 Handle {
224 kind: String,
225 id: u64,
226 },
227 /// Every other value kind's existing display-string form
228 /// ([`NameResolver::format_value`]) — see this enum's own doc for why.
229 Other(String),
230}
231
232/// A visit count for a named knot/stitch.
233pub struct DebugVisit {
234 pub path: String,
235 pub count: u32,
236}
237
238/// A visit count keyed by `DefinitionId` (W11/#3304) — includes anonymous
239/// choice/gather body containers, which have no path.
240pub struct DebugVisitId {
241 /// `DefinitionId` display form (`$tt_hash`) — string-equal to the HIR
242 /// overlay projection's `def_id` for the same container.
243 pub def_id: String,
244 pub count: u32,
245}
246
247/// A pending choice and the knot it targets.
248pub struct DebugChoice {
249 pub text: String,
250 /// `+` (sticky) vs `*` (once-only), as written (#3435) — see
251 /// [`Choice::sticky`](crate::story::Choice::sticky).
252 pub sticky: bool,
253 /// The choice text's source location (#3435) — see
254 /// [`Choice::source`](crate::story::Choice::source).
255 pub source: Option<brink_format::SourceLocation>,
256 pub target: Option<String>,
257 /// The choice target's `DefinitionId` display form (W11/#3304) —
258 /// string-equal to the overlay projection's `def_id` for the choice's
259 /// own container, joining a PRESENTED choice back to its source span.
260 pub def_id: String,
261 /// The raw `flow.pending_choices` index — the same pre-filter position
262 /// the visible [`Choice`](crate::story::Choice)'s `index` carries and
263 /// that `select_choice`/`choose` expects. Not a post-filter enumeration
264 /// position: invisible-default choices are filtered out of what's shown
265 /// but still occupy a slot in `pending_choices`, so this can skip values.
266 pub index: usize,
267}
268
269/// Story RNG state.
270pub struct DebugRng {
271 pub seed: i32,
272 pub previous: i32,
273}
274
275/// Resolves container indices / definition ids to author-facing paths and
276/// formats values for display. Holds a one-time reverse map of the program's
277/// `address_by_path` table.
278pub(crate) struct NameResolver<'p> {
279 program: &'p Program,
280}
281
282impl<'p> NameResolver<'p> {
283 pub(crate) fn new(program: &'p Program) -> Self {
284 Self { program }
285 }
286
287 /// The knot/stitch path for a container, if it names a scope — the
288 /// program's link-time index ([`Program::container_path`]).
289 pub(crate) fn container_path(&self, idx: u32) -> Option<&str> {
290 self.program.container_path(idx)
291 }
292
293 /// The knot/stitch path a definition id lives in, if resolvable.
294 pub(crate) fn def_path(&self, id: DefinitionId) -> Option<&str> {
295 let (idx, _) = self.program.resolve_target(id)?;
296 self.container_path(idx)
297 }
298
299 /// Structured value view for the debugger's locals panel
300 /// (`docs/debugger-spec.md` §3, D7/#3185) — see [`DebugValue`]'s own
301 /// doc for why this exists alongside `format_value` rather than
302 /// replacing it.
303 pub(crate) fn debug_value(&self, value: &Value) -> DebugValue {
304 match value {
305 Value::Int(i) => DebugValue::Int(*i),
306 Value::Float(f) => DebugValue::Float(*f),
307 Value::Bool(b) => DebugValue::Bool(*b),
308 Value::String(s) => DebugValue::Str(s.to_string()),
309 Value::Null => DebugValue::Null,
310 Value::List(list) => DebugValue::List(
311 list.items
312 .iter()
313 .filter_map(|id| self.program.list_item_name(*id))
314 .map(str::to_owned)
315 .collect(),
316 ),
317 Value::DivertTarget(id) => {
318 DebugValue::DivertTarget(self.def_path(*id).map(str::to_owned))
319 }
320 Value::Record { shape, fields } => {
321 let shape_entry = self.program.struct_shape(*shape);
322 let name = shape_entry
323 .and_then(|s| self.program.name_checked(s.name))
324 .map(str::to_owned);
325 let field_names: Vec<&str> = shape_entry.map_or_else(Vec::new, |s| {
326 s.fields
327 .iter()
328 .map(|&n| self.program.name_checked(n).unwrap_or("?"))
329 .collect()
330 });
331 let fields = fields
332 .iter()
333 .enumerate()
334 .map(|(i, v)| {
335 let field_name = field_names
336 .get(i)
337 .map_or_else(|| format!("_{i}"), |n| (*n).to_owned());
338 (field_name, self.debug_value(v))
339 })
340 .collect();
341 DebugValue::Struct { name, fields }
342 }
343 Value::Handle { kind, id } => DebugValue::Handle {
344 kind: self.program.name_checked(*kind).unwrap_or("?").to_owned(),
345 id: *id,
346 },
347 other => DebugValue::Other(self.format_value(other)),
348 }
349 }
350
351 /// Format a runtime value for display, resolving names where possible.
352 pub(crate) fn format_value(&self, value: &Value) -> String {
353 match value {
354 Value::Int(i) => i.to_string(),
355 Value::Float(f) => f.to_string(),
356 Value::Bool(b) => b.to_string(),
357 Value::String(s) => format!("\"{s}\""),
358 Value::Null => "null".to_owned(),
359 Value::List(list) => {
360 let members: Vec<&str> = list
361 .items
362 .iter()
363 .filter_map(|id| self.program.list_item_name(*id))
364 .collect();
365 format!("({})", members.join(", "))
366 }
367 Value::DivertTarget(id) => match self.def_path(*id) {
368 Some(p) => format!("-> {p}"),
369 None => "-> ?".to_owned(),
370 },
371 Value::VariablePointer(id) => match self.program.global_var_name(*id) {
372 Some(n) => format!("ref {n}"),
373 None => "ref ?".to_owned(),
374 },
375 Value::TempPointer { slot, frame_depth } => {
376 format!("temp[{slot}]@{frame_depth}")
377 }
378 Value::FragmentRef(idx) => format!("<fragment {idx}>"),
379 Value::Array(items) => {
380 let parts: Vec<String> = items.iter().map(|v| self.format_value(v)).collect();
381 format!("[{}]", parts.join(", "))
382 }
383 Value::Map(map) => {
384 let parts: Vec<String> = map
385 .iter()
386 .map(|(k, v)| format!("{}: {}", format_map_key(k), self.format_value(v)))
387 .collect();
388 format!("{{{}}}", parts.join(", "))
389 }
390 // Weighted tables (NS-A7): mirror the construction literal,
391 // entries in construction order.
392 Value::Weighted(w) => {
393 let parts: Vec<String> = w
394 .entries
395 .iter()
396 .map(|(weight, v)| format!("{weight}: {}", self.format_value(v)))
397 .collect();
398 format!("Weighted {{ {} }}", parts.join(", "))
399 }
400 Value::Record { shape, fields } => {
401 let parts: Vec<String> = fields.iter().map(|v| self.format_value(v)).collect();
402 format!("Record#{}{{{}}}", shape.0, parts.join(", "))
403 }
404 // Function values (T1c, #700). Debug rendering resolves the target
405 // path where possible and shows the bound env; the author-facing
406 // `string(f)` display form (spec §5) lands in T1c-3.
407 Value::FnRef(target) => match self.def_path(*target) {
408 Some(p) => format!("fn {p}"),
409 None => "fn ?".to_owned(),
410 },
411 Value::Closure(c) => {
412 let name = self.def_path(c.target).unwrap_or("?");
413 let parts: Vec<String> = c
414 .env
415 .iter()
416 .map(|e| {
417 let mode = if e.is_ref { "ref" } else { "val" };
418 format!("{mode} {}", self.format_value(&e.payload))
419 })
420 .collect();
421 format!("fn {name}({})", parts.join(", "))
422 }
423 // Handle values (T1d, `docs/t1d-spec.md` §6). Same display form
424 // as the runtime's authoritative `string(h)` (`value_ops::stringify`):
425 // `handle <Kind>#<id>`, resolved via the program's name table.
426 Value::Handle { kind, id } => {
427 let kind_name = self.program.name_checked(*kind).unwrap_or("?");
428 format!("handle {kind_name}#{id}")
429 }
430 // Projection values (T1e, `docs/t1e-spec.md` §4). Same display
431 // form as the runtime's authoritative `string(p)`
432 // (`value_ops::stringify`).
433 // Range values (NS-A5, F7) share the authoritative display too:
434 // the written `0..10` / `1..=6` form.
435 // Tower values (NS-A8): same display form as the runtime's
436 // authoritative `string(v)` (`value_ops::stringify`).
437 Value::Projection(_)
438 | Value::OptionVal(_)
439 | Value::Range { .. }
440 | Value::Vec2(_)
441 | Value::Vec3(_)
442 | Value::Vec4(_)
443 | Value::Quat(_)
444 | Value::Mat2(_)
445 | Value::Mat3(_)
446 | Value::Mat4(_) => value_ops::stringify(value, self.program),
447 }
448 }
449}
450
451/// Format a map key for debug display.
452fn format_map_key(key: &brink_format::MapKey) -> String {
453 match key {
454 brink_format::MapKey::Int(n) => n.to_string(),
455 brink_format::MapKey::Str(s) => format!("\"{s}\""),
456 brink_format::MapKey::Bool(b) => b.to_string(),
457 }
458}