Skip to main content

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}