brink_runtime/story/mod.rs
1//! Per-instance mutable story state.
2
3use core::marker::PhantomData;
4use core::ops::Range;
5
6use alloc::borrow::ToOwned;
7use alloc::boxed::Box;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::sync::Arc;
11use alloc::vec::Vec;
12
13use brink_format::{DefinitionId, PluralResolver, Value};
14
15use crate::collections::Map as HashMap;
16use crate::error::RuntimeError;
17use crate::program::Program;
18use crate::rng::{FastRng, StoryRng};
19use crate::state::{ContextAccess, WriteObserver};
20#[cfg(any(feature = "testing", feature = "debug-hooks"))]
21use crate::vm;
22use crate::world::{ContextView, FlowLocal, World};
23
24mod call_stack;
25mod external;
26mod flow_instance;
27mod types;
28
29pub use call_stack::ExecMode;
30pub(crate) use call_stack::{
31 CallFrame, CallFrameType, CallStack, ChoiceDisplay, ContainerPosition, Flow, PendingChoice,
32 PureCallbackState, classify_ran_out_of_content,
33};
34// Only test fixtures across the op-table modules construct a bare `Flow`
35// literal (production code reaches `pending_terminal` only through
36// `flow_instance.rs`, which imports `PendingTerminal` directly from
37// `call_stack`) — gate the re-export the same way so a plain `cargo check`
38// of the lib target (no `cfg(test)`) doesn't see it as unused.
39#[cfg(test)]
40pub(crate) use call_stack::PendingTerminal;
41pub use external::{ExternalFnHandler, ExternalResult, FallbackHandler, FunctionEval};
42pub use flow_instance::{DriveOutcome, FlowInstance};
43pub use types::{BlockId, Choice, Element, OutputLine, Stats, Step, StepOutcome, StoryStatus};
44
45// ── Story ───────────────────────────────────────────────────────────────────
46
47/// Per-instance mutable state for executing stories.
48///
49/// Created from a [`Program`] via [`Story::new`]. Holds all mutable state
50/// (stacks, globals, output buffer) while the immutable program data lives
51/// in [`Program`].
52///
53/// Generic over `R: StoryRng` — defaults to [`FastRng`]. Use
54/// [`DotNetRng`](crate::DotNetRng) for .NET-compatible deterministic output.
55pub struct Story<R: StoryRng = FastRng> {
56 program: Arc<Program>,
57 pub(crate) default: FlowInstance,
58 pub(crate) default_context: World,
59 /// The default flow's per-flow override layer. Empty in F1.3 (F3 fills
60 /// it in) — the routing view built from `(default_context, default_local)`
61 /// is an all-`World` passthrough, so this contributes nothing yet.
62 default_local: FlowLocal,
63 line_tables: Vec<Vec<brink_format::LineEntry>>,
64 instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
65 /// Named flows that **share** `default_context` (globals / visit counts /
66 /// rng) — true ink concurrent-flow semantics, where one flow's writes are
67 /// visible to the others. Each still has its own call stack + temps (those
68 /// live in the [`FlowInstance`]). Distinct from `instances`, whose flows
69 /// each own an isolated `World` (bevy-brink's per-entity model). Transient
70 /// studio/host state — not persisted in a [`StorySnapshot`].
71 shared_instances: HashMap<String, FlowInstance>,
72 resolver: Option<Box<dyn PluralResolver>>,
73 /// Whether host **semantic** access to `#@private` definitions is refused
74 /// (M-2b, `docs/modules-spec.md` §4 boundary rule 2). `true` by default —
75 /// production hosts respect visibility. Dev tooling (play-from-here) sets
76 /// it `false` via [`set_visibility_enforcement`](Self::set_visibility_enforcement)
77 /// to start flows at private knots. No effect on stories without any
78 /// `#@private` definition (the fast path short-circuits on that).
79 enforce_visibility: bool,
80 /// The dev/prod execution mode (NS-A4, [`ExecMode`]). A host/build
81 /// knob mirrored onto every owned [`FlowInstance`] — see
82 /// [`set_exec_mode`](Self::set_exec_mode). Not persisted in a
83 /// [`StorySnapshot`] (the mode is a property of the host/build, not of
84 /// story state).
85 exec_mode: ExecMode,
86 _rng: PhantomData<R>,
87}
88
89impl<R: StoryRng> Clone for Story<R> {
90 fn clone(&self) -> Self {
91 Self {
92 program: Arc::clone(&self.program),
93 default: self.default.clone(),
94 default_context: self.default_context.clone(),
95 default_local: self.default_local.clone(),
96 line_tables: self.line_tables.clone(),
97 instances: self.instances.clone(),
98 shared_instances: self.shared_instances.clone(),
99 resolver: None,
100 enforce_visibility: self.enforce_visibility,
101 exec_mode: self.exec_mode,
102 _rng: PhantomData,
103 }
104 }
105}
106
107/// Owned story state that can be detached from a `Program` and reattached later.
108///
109/// Created by [`Story::into_snapshot`], consumed by [`Story::from_snapshot`].
110/// This enables locale hot-swapping: detach state, mutate the program's line
111/// tables, then reattach.
112pub struct StorySnapshot<R: StoryRng = FastRng> {
113 default: FlowInstance,
114 default_context: World,
115 default_local: FlowLocal,
116 instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
117 _rng: PhantomData<R>,
118}
119
120/// Which unit a `debug_step*` call advances by (#3264). Both are
121/// first-class verbs, not a primitive and a wrapper — the studio shows the
122/// disassembly beside the source and drives each directly.
123#[cfg(feature = "debug-hooks")]
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125enum StepGranularity {
126 Instruction,
127 Line,
128}
129
130/// The mutable per-flow state a debug verb drives (#3223): the flow plus
131/// the context pair its `ContextView` routes through — the default trio,
132/// an isolated flow's own trio, or a shared flow paired with the default
133/// context, exactly the routing every production `continue_flow*` method
134/// performs. Selected by [`Story::debug_parts`] so all six debug verbs
135/// share one selection rule.
136#[cfg(feature = "debug-hooks")]
137struct DebugTarget<'a> {
138 flow: &'a mut FlowInstance,
139 world: &'a mut World,
140 local: &'a mut FlowLocal,
141}
142
143/// The flow-independent story state every debug verb reads (#3223) —
144/// bundled so the verb impls stay under one signature as parameters grow.
145#[cfg(feature = "debug-hooks")]
146struct DebugEnv<'a> {
147 program: &'a Program,
148 line_tables: &'a [Vec<brink_format::LineEntry>],
149 resolver: Option<&'a dyn PluralResolver>,
150 /// External-function bindings (#3224). The debug loops resolve a
151 /// `Stepped::ExternalCall` through [`flow_instance::resolve_external_call`]
152 /// with this handler — the same function, same handler contract as
153 /// production `advance()` — so a bound external steps through
154 /// mid-session instead of erroring `UnresolvedExternalCall`.
155 handler: &'a dyn ExternalFnHandler,
156}
157
158/// See [`Story::debug_handle_stepped`].
159#[cfg(feature = "debug-hooks")]
160enum SteppedDisposition {
161 Continue,
162 /// An invisible default was auto-selected and the flow resumed — the
163 /// step loop skips its depth/line stop-check for this iteration
164 /// (matching the production per-turn loop's treatment: the boundary
165 /// is bookkeeping, not a place execution "is").
166 Resumed,
167 /// The step crossed (and synchronously resolved) an `External` call.
168 CrossedExternal,
169 Stop(crate::DebugRunOutcome),
170}
171
172/// One line handed out by [`Story::debug_drain_buffered_lines`]: text,
173/// tags, and its source location (W7/#3300 provenance — same field the
174/// production road's `OutputLine::source` carries).
175#[cfg(feature = "debug-hooks")]
176pub type DrainedLine = (
177 String,
178 alloc::vec::Vec<String>,
179 Option<brink_format::SourceLocation>,
180);
181
182/// Whether a debug run stops when the output buffer commits a line —
183/// [`Story::debug_run_to_line`]'s tier vs [`Story::debug_run`]'s free
184/// run. An enum rather than a bool so call sites read as what they do.
185#[cfg(feature = "debug-hooks")]
186#[derive(Clone, Copy, PartialEq, Eq)]
187enum StopOnLine {
188 No,
189 Yes,
190}
191
192/// The innermost named container a frame is executing in — the shared
193/// derivation behind [`Story::current_path`] and the debug snapshot's
194/// `current_location`.
195pub(crate) fn frame_path(program: &Program, stack: &CallStack, depth: usize) -> Option<String> {
196 stack
197 .containers(depth)
198 .iter()
199 .rev()
200 .find_map(|cp| program.scope_path(cp.container_idx))
201 .map(str::to_owned)
202}
203
204/// The root scope is addressed by the empty path — for the PUBLIC query
205/// that is "no named container", not a name (the debugger keeps the empty
206/// string: it renders the unnamed root frame as `<root>`).
207fn named_path(path: Option<String>) -> Option<String> {
208 path.filter(|p| !p.is_empty())
209}
210
211impl<R: StoryRng> Story<R> {
212 /// Create a new story instance from a linked program and its line tables.
213 pub fn new(program: Arc<Program>, line_tables: Vec<Vec<brink_format::LineEntry>>) -> Self {
214 let (default, default_context) = FlowInstance::new_at_root(&program);
215 Self {
216 program,
217 default,
218 default_context,
219 default_local: FlowLocal::new(),
220 line_tables,
221 instances: HashMap::new(),
222 shared_instances: HashMap::new(),
223 resolver: None,
224 enforce_visibility: true,
225 exec_mode: ExecMode::default(),
226 _rng: PhantomData,
227 }
228 }
229
230 /// Enable or disable host visibility enforcement (M-2b,
231 /// `docs/modules-spec.md` §4 boundary rule 3). Enforcement is **on** by
232 /// default: host semantic access (variable get/set, entry lookup,
233 /// function eval) to a `#@private` definition returns
234 /// [`RuntimeError::PrivateAccess`] (or `None`/`false` for the infallible
235 /// get/set). Dev tooling — editors, debug hosts, the play-from-here
236 /// affordance — calls this with `false` to start flows at private knots
237 /// and inspect private state. This is a host capability, not a language
238 /// switch; the compiled program is identical either way. Persistence
239 /// (save/load/journal/replay) ignores this flag entirely.
240 ///
241 /// Propagates to every [`FlowInstance`] this `Story` currently owns
242 /// (`default`, every named flow, every shared flow) — each carries its
243 /// own copy of the flag (so `bevy-brink`/[`crate::Speculation`] can
244 /// enforce it when driving a `FlowInstance` directly, without a
245 /// `Story`), and `Story` keeps them synced so a `Story`-mediated dev
246 /// override never diverges from the flows it delegates to. Flows
247 /// spawned after this call ([`spawn_flow`](Self::spawn_flow)/
248 /// [`spawn_flow_shared`](Self::spawn_flow_shared)) inherit the
249 /// `Story`'s current setting at spawn time.
250 pub fn set_visibility_enforcement(&mut self, enforce: bool) {
251 self.enforce_visibility = enforce;
252 self.default.set_visibility_enforcement(enforce);
253 for (flow, _, _) in self.instances.values_mut() {
254 flow.set_visibility_enforcement(enforce);
255 }
256 for flow in self.shared_instances.values_mut() {
257 flow.set_visibility_enforcement(enforce);
258 }
259 }
260
261 /// Whether host visibility enforcement is currently on (default `true`).
262 #[must_use]
263 pub fn visibility_enforced(&self) -> bool {
264 self.enforce_visibility
265 }
266
267 /// Set the dev/prod execution mode (NS-A4, [`ExecMode`] — see its docs
268 /// for the §4b ordering doctrine). **Dev** (the default) faults on a
269 /// float NaN comparand in an ordering context; **Prod** keeps moving
270 /// with the pinned non-fabricating total order. The knob's home is
271 /// project config (`brink.toml` profile) with this host-API override
272 /// (ruled 2026-07-19); the mode is never embedded in `.inkb` and never
273 /// persisted in saves or snapshots.
274 ///
275 /// Propagates to every [`FlowInstance`] this `Story` currently owns
276 /// (`default`, named, shared) — the same sync discipline as
277 /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
278 /// Flows spawned after this call inherit the `Story`'s current setting
279 /// at spawn time.
280 pub fn set_exec_mode(&mut self, mode: ExecMode) {
281 self.exec_mode = mode;
282 self.default.set_exec_mode(mode);
283 for (flow, _, _) in self.instances.values_mut() {
284 flow.set_exec_mode(mode);
285 }
286 for flow in self.shared_instances.values_mut() {
287 flow.set_exec_mode(mode);
288 }
289 }
290
291 /// The current dev/prod execution mode (default [`ExecMode::Dev`]).
292 #[must_use]
293 pub fn exec_mode(&self) -> ExecMode {
294 self.exec_mode
295 }
296
297 /// Set the plural resolver for Select resolution in localized lines.
298 pub fn set_plural_resolver(&mut self, resolver: Box<dyn PluralResolver>) {
299 self.resolver = Some(resolver);
300 }
301
302 /// Replace the active line tables (e.g. for locale swapping).
303 pub fn set_line_tables(&mut self, tables: Vec<Vec<brink_format::LineEntry>>) {
304 self.line_tables = tables;
305 }
306
307 /// Read-only access to the current line tables.
308 pub fn line_tables(&self) -> &[Vec<brink_format::LineEntry>] {
309 &self.line_tables
310 }
311
312 /// The full append-only transcript of all output parts produced so far.
313 pub fn transcript(&self) -> &[crate::output::OutputPart] {
314 self.default.flow.output.transcript()
315 }
316
317 /// Number of parts in the transcript.
318 pub fn transcript_len(&self) -> usize {
319 self.default.flow.output.transcript_len()
320 }
321
322 /// Reset the transcript read cursor to the beginning (for re-rendering).
323 pub fn reset_cursor(&mut self) {
324 self.default.flow.output.reset_cursor();
325 }
326
327 /// Resolve a slice of the transcript against the current line tables.
328 /// Returns `(text, tags)` tuples — one per line in the resolved output.
329 pub fn resolve_transcript_slice(&self, range: Range<usize>) -> Vec<(String, Vec<String>)> {
330 let transcript = self.default.flow.output.transcript();
331 let end = range.end.min(transcript.len());
332 let start = range.start.min(end);
333 let slice = &transcript[start..end];
334 let fragments = self.default.flow.output.fragments();
335 // Element-attachment data (issue #2108) is dropped here — this
336 // method's public contract is `(text, tags)`, unchanged; a caller
337 // that needs per-line element data has no use for a locale-
338 // re-rendering slice taken in isolation from the surrounding
339 // `Step::Line` stream anyway.
340 crate::output::resolve_lines(
341 slice,
342 &self.program,
343 &self.line_tables,
344 self.resolver.as_deref(),
345 fragments,
346 )
347 .into_iter()
348 .map(|(text, tags, _element, _source)| (text, tags))
349 .collect()
350 }
351
352 /// Re-resolve all pending choices against the current line tables.
353 /// Returns the same choices that would appear in `Step::Choices`,
354 /// but freshly resolved (useful after locale switch).
355 pub fn pending_choices(&self) -> Vec<Choice> {
356 self.resolved_choices_for(&self.default.flow)
357 }
358
359 /// Resolve a given flow's pending choices against the current line tables.
360 /// Shared by [`pending_choices`](Self::pending_choices) (default flow) and
361 /// the per-flow debug snapshot (#200 shared flows).
362 fn resolved_choices_for(&self, flow: &Flow) -> Vec<Choice> {
363 flow.pending_choices
364 .iter()
365 .filter(|pc| !pc.flags.is_invisible_default)
366 .enumerate()
367 .map(|(i, pc)| {
368 let display_text = match &pc.display {
369 ChoiceDisplay::Text(s) => s.clone(),
370 ChoiceDisplay::Fragment(idx) => flow.output.resolve_fragment(
371 *idx,
372 &self.program,
373 &self.line_tables,
374 self.resolver.as_deref(),
375 ),
376 };
377 let display_text = display_text
378 .trim_matches(|c: char| c == ' ' || c == '\t')
379 .to_string();
380 let source = match &pc.display {
381 ChoiceDisplay::Fragment(idx) => {
382 flow.output
383 .fragment_source(*idx, &self.program, &self.line_tables)
384 }
385 ChoiceDisplay::Text(_) => None,
386 };
387 Choice {
388 text: display_text,
389 index: i,
390 tags: pc.tags.clone(),
391 sticky: !pc.flags.once_only,
392 source,
393 }
394 })
395 .collect()
396 }
397
398 /// Resolve a fragment against the current line tables.
399 pub fn resolve_fragment(&self, idx: u32) -> String {
400 self.default.flow.output.resolve_fragment(
401 idx,
402 &self.program,
403 &self.line_tables,
404 self.resolver.as_deref(),
405 )
406 }
407
408 /// Get the fragment index for a pending choice's display text, if any.
409 pub fn choice_fragment_idx(&self, choice_index: usize) -> Option<u32> {
410 self.default
411 .flow
412 .pending_choices
413 .get(choice_index)
414 .and_then(|pc| match &pc.display {
415 ChoiceDisplay::Fragment(idx) => Some(*idx),
416 ChoiceDisplay::Text(_) => None,
417 })
418 }
419
420 /// Read-only access to the fragment store (for transcript serialization).
421 pub fn fragments(&self) -> &crate::output::Fragments {
422 self.default.flow.output.fragments()
423 }
424
425 /// Read-only access to the program.
426 pub fn program(&self) -> &Program {
427 &self.program
428 }
429
430 /// Cheap `Arc` clone of the program, for callers (e.g. [`crate::save`])
431 /// that need a `&Program` alongside a disjoint mutable borrow of another
432 /// field — `self.program()` ties its `&Program` to all of `&self`, which
433 /// conflicts with a simultaneous `&mut self.default_context`.
434 pub(crate) fn program_arc(&self) -> Arc<Program> {
435 Arc::clone(&self.program)
436 }
437
438 // ── Variable access (host-facing) ───────────────────────────────
439
440 /// Read a global variable's current value by name. `None` if no global
441 /// with that name is declared. Reads the default flow's context.
442 ///
443 /// Returns `None` for a `#@private` variable while visibility enforcement
444 /// is on (M-2b) — the host is outside every module, so a private name is
445 /// not host-visible. Dev tooling opts out via
446 /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
447 pub fn variable(&self, name: &str) -> Option<&Value> {
448 let idx = self.program.global_index(name)?;
449 if self.enforce_visibility
450 && self.program.has_private_defs()
451 && self.program.global_is_private(idx)
452 {
453 return None;
454 }
455 Some(ContextAccess::global(&self.default_context, idx))
456 }
457
458 /// Set a global variable by name, returning `false` (no-op) if no global
459 /// with that name is declared. Ink globals are dynamically typed, so the
460 /// host is responsible for passing a sensibly-typed value.
461 ///
462 /// Returns `false` (no write) for a `#@private` variable while visibility
463 /// enforcement is on (M-2b). Dev tooling opts out via
464 /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
465 pub fn set_variable(&mut self, name: &str, value: Value) -> bool {
466 match self.program.global_index(name) {
467 Some(idx) => {
468 if self.enforce_visibility
469 && self.program.has_private_defs()
470 && self.program.global_is_private(idx)
471 {
472 return false;
473 }
474 ContextAccess::set_global(&mut self.default_context, idx, value);
475 true
476 }
477 None => false,
478 }
479 }
480
481 /// Set the RNG seed for the default flow's context. Seeding makes
482 /// `RANDOM`/shuffle output reproducible — set it before running (or after
483 /// a reset) so two runs of the same story on different machines match.
484 pub fn set_rng_seed(&mut self, seed: i32) {
485 ContextAccess::set_rng_seed(&mut self.default_context, seed);
486 }
487
488 // ── Pausable stepping (async externals) ─────────────────────────
489
490 /// Advance the default flow by one step with a custom handler, surfacing a
491 /// deferred external as [`StepOutcome::AwaitingExternal`] rather than
492 /// erroring (unlike [`continue_single_with`](Self::continue_single_with)).
493 ///
494 /// On `AwaitingExternal`, resolve the pending call
495 /// ([`resolve_external`](Self::resolve_external), or
496 /// [`invoke_fallback`](Self::invoke_fallback)) and call `advance_with` again
497 /// to resume. Inspect the pending call via
498 /// [`pending_external_name`](Self::pending_external_name) /
499 /// [`pending_external_args`](Self::pending_external_args).
500 pub fn advance_with(
501 &mut self,
502 handler: &dyn ExternalFnHandler,
503 ) -> Result<StepOutcome, RuntimeError> {
504 let resolver = self.resolver.as_deref();
505 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
506 self.default.advance::<R>(
507 &self.program,
508 &self.line_tables,
509 &mut view,
510 handler,
511 resolver,
512 )
513 }
514
515 /// Name of the external the default flow is paused on, if any.
516 #[must_use]
517 pub fn pending_external_name(&self) -> Option<&str> {
518 self.default.pending_external_name(&self.program)
519 }
520
521 /// Arguments of the external the default flow is paused on.
522 #[must_use]
523 pub fn pending_external_args(&self) -> &[Value] {
524 self.default.pending_external_args()
525 }
526
527 /// Evaluate an ink function by name from engine code, returning its value.
528 ///
529 /// Runs out-of-band on the default flow: output is isolated (the visible
530 /// story is untouched), and the call completes synchronously. Externals the
531 /// function calls are resolved inline by `handler`; an external the handler
532 /// defers ([`ExternalResult::Pending`]) can't be resolved in a synchronous
533 /// call and yields [`RuntimeError::AsyncExternalInCall`] (the paused eval is
534 /// cleaned up first).
535 ///
536 /// # Errors
537 /// [`RuntimeError::FunctionNotFound`] for an unknown name;
538 /// [`RuntimeError::AsyncExternalInCall`] if a called external defers; plus
539 /// any runtime error raised during evaluation.
540 pub fn call_function(
541 &mut self,
542 name: &str,
543 args: &[Value],
544 handler: &dyn ExternalFnHandler,
545 ) -> Result<Value, RuntimeError> {
546 // M-2b: refuse host-driven evaluation of a `#@private` function while
547 // enforcement is on. Checked before resolution details so a private
548 // name reports as private, not as "not found".
549 if self.enforce_visibility
550 && self.program.has_private_defs()
551 && self.program.path_is_private(name)
552 {
553 return Err(RuntimeError::PrivateAccess {
554 name: name.to_owned(),
555 });
556 }
557 let container_idx = self
558 .program
559 .find_address(name)
560 .ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
561 .0;
562 // Arity-check against the function's declared parameters (compiler-built
563 // programs only; converter-built ones record 0 and so accept no args).
564 let expected = self.program.container(container_idx).param_count;
565 if args.len() != expected as usize {
566 return Err(RuntimeError::ArgCountMismatch {
567 target: name.to_owned(),
568 expected,
569 got: args.len(),
570 });
571 }
572 let resolver = self.resolver.as_deref();
573 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
574 let outcome = self.default.begin_function_eval::<R>(
575 &self.program,
576 &self.line_tables,
577 &mut view,
578 handler,
579 container_idx,
580 args,
581 resolver,
582 )?;
583 match outcome {
584 FunctionEval::Returned(value) => Ok(value),
585 FunctionEval::AwaitingExternal => {
586 let name = self
587 .default
588 .pending_external_name(&self.program)
589 .map_or_else(|| name.to_owned(), ToOwned::to_owned);
590 self.default
591 .abort_eval(&self.program, &self.line_tables, resolver);
592 Err(RuntimeError::AsyncExternalInCall(name))
593 }
594 }
595 }
596
597 /// Fork a [`Speculation`](crate::Speculation) — a sandboxed,
598 /// side-effect-proof speculative run — from the default flow's
599 /// current state.
600 ///
601 /// The speculation owns an independent snapshot: driving it (via its
602 /// own `advance`/`choose`/`go_to_path`/`eval_function` verbs) never
603 /// mutates this `Story`. Dropping it discards everything it did. See
604 /// [`crate::Speculation`] for the full picture, and
605 /// [`crate::Speculation::fork_from`] for forking a non-default flow
606 /// (e.g. a named flow spawned via [`spawn_flow`](Self::spawn_flow)).
607 #[must_use]
608 pub fn speculate(&self) -> crate::Speculation<R> {
609 crate::Speculation::fork_from(
610 Arc::clone(&self.program),
611 &self.default_context,
612 &self.default_local,
613 &self.default,
614 &self.line_tables,
615 )
616 }
617
618 /// Detach story state from the program, consuming the story.
619 pub fn into_snapshot(self) -> (StorySnapshot<R>, Vec<Vec<brink_format::LineEntry>>) {
620 let snapshot = StorySnapshot {
621 default: self.default,
622 default_context: self.default_context,
623 default_local: self.default_local,
624 instances: self.instances,
625 _rng: PhantomData,
626 };
627 (snapshot, self.line_tables)
628 }
629
630 /// Reattach a snapshot to a program with line tables.
631 pub fn from_snapshot(
632 program: Arc<Program>,
633 snapshot: StorySnapshot<R>,
634 line_tables: Vec<Vec<brink_format::LineEntry>>,
635 ) -> Self {
636 let mut story = Self {
637 program,
638 default: snapshot.default,
639 default_context: snapshot.default_context,
640 default_local: snapshot.default_local,
641 line_tables,
642 instances: snapshot.instances,
643 // Shared flows are transient (not persisted) — a reattached story
644 // starts with none.
645 shared_instances: HashMap::new(),
646 resolver: None,
647 // Enforcement is a host capability, not persisted state — a
648 // reattached story defaults to enforcing; the host re-applies a
649 // dev override if it wants one.
650 enforce_visibility: true,
651 // Same posture for the dev/prod mode (NS-A4): a host/build
652 // knob, not persisted state — a reattached story defaults to
653 // Dev; the host re-applies its own setting.
654 exec_mode: ExecMode::default(),
655 _rng: PhantomData,
656 };
657 // `snapshot.default`/`snapshot.instances` carry whatever
658 // `FlowInstance`-level enforcement flag they had at detach time
659 // (e.g. `false`, if `into_snapshot` ran while a play-from-here
660 // session had enforcement off) — force every flow back to the
661 // reattached story's own (enforcing) setting so the two can't
662 // diverge.
663 story.set_visibility_enforcement(true);
664 // Same re-sync for the exec mode (the flows in the snapshot carry
665 // whatever mode they had at detach time).
666 story.set_exec_mode(ExecMode::default());
667 story
668 }
669
670 // ── Execution API ──────────────────────────────────────────────
671
672 /// Execute until one line of content (up to newline), or until a
673 /// yield point (choices/end) if no newline occurs first.
674 ///
675 /// The returned [`Step`] variant tells you what to do next:
676 /// - [`Step::Line`] — more output may follow, keep calling.
677 /// - [`Step::Choices`] — call [`choose`](Self::choose) then resume.
678 /// - [`Step::End`] — the story has permanently ended.
679 pub fn continue_single(&mut self) -> Result<Step, RuntimeError> {
680 let resolver = self.resolver.as_deref();
681 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
682 self.default.step_single_line::<R>(
683 &self.program,
684 &self.line_tables,
685 &mut view,
686 &FallbackHandler,
687 resolver,
688 )
689 }
690
691 /// Like [`continue_single`](Self::continue_single) but with a
692 /// [`WriteObserver`] that receives notifications for every state mutation.
693 pub fn continue_single_observed(
694 &mut self,
695 observer: &mut dyn WriteObserver,
696 ) -> Result<Step, RuntimeError> {
697 use crate::state::ObservedContext;
698 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
699 let mut obs_ctx = ObservedContext::new(&mut view, observer);
700 let resolver = self.resolver.as_deref();
701 self.default.step_single_line::<R>(
702 &self.program,
703 &self.line_tables,
704 &mut obs_ctx,
705 &FallbackHandler,
706 resolver,
707 )
708 }
709
710 /// Like [`continue_single`](Self::continue_single) but with a custom
711 /// external function handler.
712 pub fn continue_single_with(
713 &mut self,
714 handler: &dyn ExternalFnHandler,
715 ) -> Result<Step, RuntimeError> {
716 let resolver = self.resolver.as_deref();
717 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
718 self.default.step_single_line::<R>(
719 &self.program,
720 &self.line_tables,
721 &mut view,
722 handler,
723 resolver,
724 )
725 }
726
727 /// Execute until the next yield point, collecting all lines.
728 ///
729 /// Returns a `Vec<Step>` where the last element is always
730 /// [`Step::Choices`] or [`Step::End`], and all preceding elements
731 /// are [`Step::Line`].
732 pub fn continue_maximally(&mut self) -> Result<Vec<Step>, RuntimeError> {
733 self.continue_maximally_impl(&FallbackHandler)
734 }
735
736 /// Like [`continue_maximally`](Self::continue_maximally) but with a
737 /// custom external function handler.
738 pub fn continue_maximally_with(
739 &mut self,
740 handler: &dyn ExternalFnHandler,
741 ) -> Result<Vec<Step>, RuntimeError> {
742 self.continue_maximally_impl(handler)
743 }
744
745 fn continue_maximally_impl(
746 &mut self,
747 handler: &dyn ExternalFnHandler,
748 ) -> Result<Vec<Step>, RuntimeError> {
749 let resolver = self.resolver.as_deref();
750 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
751 self.default.drive_to_terminal::<R>(
752 &self.program,
753 &self.line_tables,
754 &mut view,
755 handler,
756 resolver,
757 )
758 }
759
760 /// Execute until the next yield point with a [`WriteObserver`] that
761 /// receives notifications for every state mutation.
762 pub fn continue_maximally_observed(
763 &mut self,
764 observer: &mut dyn WriteObserver,
765 ) -> Result<Vec<Step>, RuntimeError> {
766 use crate::state::ObservedContext;
767 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
768 let mut obs_ctx = ObservedContext::new(&mut view, observer);
769 let resolver = self.resolver.as_deref();
770 self.default.drive_to_terminal::<R>(
771 &self.program,
772 &self.line_tables,
773 &mut obs_ctx,
774 &FallbackHandler,
775 resolver,
776 )
777 }
778
779 /// Select a choice by index, then resume with
780 /// [`continue_single`](Self::continue_single) or
781 /// [`continue_maximally`](Self::continue_maximally).
782 pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
783 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
784 self.default.choose(&mut view, index)
785 }
786
787 /// Move the default flow's play head to a named knot/stitch path — ink's
788 /// `ChoosePathString` equivalent. The current flow is force-completed
789 /// (callstack reset, pending choices cleared), the jump counts as a visit
790 /// to the target exactly like a `-> path` divert, and subsequent
791 /// [`continue_single`](Self::continue_single) /
792 /// [`continue_maximally`](Self::continue_maximally) calls run from there.
793 /// See [`FlowInstance::choose_path_string`] for full semantics.
794 ///
795 /// # Errors
796 /// [`UnknownPath`](RuntimeError::UnknownPath) for an unknown path;
797 /// [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
798 /// if the flow is parked on an unresolved external call;
799 /// [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
800 /// if an engine→ink function evaluation is in progress.
801 pub fn choose_path_string(&mut self, path: &str) -> Result<(), RuntimeError> {
802 self.check_entry_visibility(path)?;
803 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
804 self.default
805 .choose_path_string(&self.program, &mut view, path)
806 }
807
808 /// M-2b: refuse a host-driven entry into a `#@private` knot/stitch while
809 /// visibility enforcement is on. Shared by both `choose_path_string`
810 /// entry points. Dev tooling (play-from-here) disables enforcement via
811 /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
812 fn check_entry_visibility(&self, path: &str) -> Result<(), RuntimeError> {
813 if self.enforce_visibility
814 && self.program.has_private_defs()
815 && self.program.path_is_private(path)
816 {
817 return Err(RuntimeError::PrivateAccess {
818 name: path.to_owned(),
819 });
820 }
821 Ok(())
822 }
823
824 /// Move the default flow's play head to a parameterized knot/stitch,
825 /// **binding its declared parameters** from `args` — ink's
826 /// `ChoosePathString` with arguments. Otherwise identical to
827 /// [`choose_path_string`](Self::choose_path_string). See
828 /// [`FlowInstance::choose_path_string_with_args`] for full semantics.
829 ///
830 /// # Errors
831 /// As [`choose_path_string`](Self::choose_path_string), plus
832 /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) when `args.len()`
833 /// doesn't match the target's declared parameter count.
834 pub fn choose_path_string_with_args(
835 &mut self,
836 path: &str,
837 args: &[Value],
838 ) -> Result<(), RuntimeError> {
839 self.check_entry_visibility(path)?;
840 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
841 self.default
842 .choose_path_string_with_args(&self.program, &mut view, path, args)
843 }
844
845 /// Read-only access to the default flow's VM statistics.
846 pub fn stats(&self) -> &Stats {
847 &self.default.stats
848 }
849
850 /// Take every non-fatal [`crate::RuntimeWarning`] the default flow has
851 /// raised since the last drain (issue #3354) — the channel an
852 /// uninitialized-`~ temp` read reports through, matching the C#
853 /// reference's own `RUNTIME WARNING` line.
854 ///
855 /// Default flow only, mirroring [`Story::stats`]: a named or shared
856 /// flow is drained through its own
857 /// [`FlowInstance::take_runtime_warnings`].
858 pub fn take_runtime_warnings(&mut self) -> Vec<crate::RuntimeWarning> {
859 self.default.take_runtime_warnings()
860 }
861
862 /// Returns `true` if the default flow has a pending external call
863 /// (an `External` frame on top of the call stack).
864 pub fn has_pending_external(&self) -> bool {
865 self.default.flow.external_fn_id().is_some()
866 }
867
868 /// Resolve a pending external call on the default flow by providing
869 /// the return value. For fire-and-forget calls, pass `Value::Null`.
870 ///
871 /// After resolving, call [`continue_maximally`](Story::continue_maximally)
872 /// to continue execution.
873 pub fn resolve_external(&mut self, value: Value) {
874 self.default.flow.resolve_external(value);
875 }
876
877 /// [`resolve_external`](Self::resolve_external) for a named flow —
878 /// isolated or shared, the same unified namespace
879 /// [`destroy_flow`](Self::destroy_flow) treats (#3224: the debug
880 /// seam can park any flow on
881 /// [`DebugStopReason::AwaitingExternal`](crate::DebugStopReason::AwaitingExternal),
882 /// so any flow needs the out-of-band resolution counterpart).
883 ///
884 /// # Errors
885 /// [`RuntimeError::UnknownFlow`] if `name` names no live flow.
886 pub fn resolve_external_flow(&mut self, name: &str, value: Value) -> Result<(), RuntimeError> {
887 if let Some((f, _, _)) = self.instances.get_mut(name) {
888 f.flow.resolve_external(value);
889 Ok(())
890 } else if let Some(f) = self.shared_instances.get_mut(name) {
891 f.flow.resolve_external(value);
892 Ok(())
893 } else {
894 Err(RuntimeError::UnknownFlow(name.to_owned()))
895 }
896 }
897
898 /// Resolve a pending external call on the default flow by invoking
899 /// the ink-defined fallback body. The fallback is a function call
900 /// whose output becomes the return value.
901 ///
902 /// After invoking, call [`continue_maximally`](Story::continue_maximally)
903 /// to continue execution.
904 pub fn invoke_fallback(&mut self) -> Result<(), RuntimeError> {
905 let fn_id = self
906 .default
907 .flow
908 .external_fn_id()
909 .ok_or_else(|| RuntimeError::CallStackUnderflow)?;
910 let entry = self.program.external_fn(fn_id);
911 let fallback_id = entry
912 .and_then(|e| e.fallback)
913 .ok_or_else(|| RuntimeError::UnresolvedExternalCall(fn_id))?;
914 let container_idx = self
915 .program
916 .resolve_target(fallback_id)
917 .map(|(idx, _)| idx)
918 .ok_or_else(|| RuntimeError::UnresolvedDefinition(fallback_id))?;
919 self.default.flow.output.begin_capture();
920 let param_slots = self.program.container_param_slots(container_idx);
921 self.default
922 .flow
923 .invoke_fallback(container_idx, ¶m_slots);
924 Ok(())
925 }
926
927 // ── Named flow API ──────────────────────────────────────────────
928
929 /// Spawn a new flow instance starting at the given entry point.
930 ///
931 /// `entry_point` is the `DefinitionId` of the target container
932 /// (e.g., a knot). Each flow instance gets its own globals, visit
933 /// counts, and execution state.
934 pub fn spawn_flow(
935 &mut self,
936 name: &str,
937 entry_point: DefinitionId,
938 ) -> Result<(), RuntimeError> {
939 // M-2b: refuse host-driven entry into a `#@private` knot/stitch while
940 // visibility enforcement is on (`docs/modules-spec.md` §4 boundary
941 // rule 2). Mirrors `check_entry_visibility`'s refusal on the named
942 // `choose_path_string` path — a host holding a `DefinitionId` (this
943 // by-id entry point) must not be able to bypass it. Checked before
944 // any other error path so a private target reports as private, not
945 // as "already exists" or "unresolved" (#803).
946 if self.enforce_visibility
947 && self.program.has_private_defs()
948 && self.program.is_private(entry_point)
949 {
950 return Err(RuntimeError::PrivateAccess {
951 name: format!("{entry_point}"),
952 });
953 }
954 if self.instances.contains_key(name) {
955 return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
956 }
957 let container_idx = self
958 .program
959 .resolve_target(entry_point)
960 .map(|(idx, _)| idx)
961 .ok_or_else(|| RuntimeError::UnresolvedDefinition(entry_point))?;
962 let (mut flow, ctx) = FlowInstance::new_at(&self.program, container_idx);
963 // Inherit this `Story`'s current enforcement setting (a dev override
964 // set before spawning must apply to newly spawned flows too, not
965 // just the flows that existed at override time).
966 flow.set_visibility_enforcement(self.enforce_visibility);
967 flow.set_exec_mode(self.exec_mode);
968 self.instances
969 .insert(name.to_owned(), (flow, ctx, FlowLocal::new()));
970 Ok(())
971 }
972
973 /// Run a named flow instance until the next yield point.
974 pub fn continue_flow_maximally(&mut self, name: &str) -> Result<Vec<Step>, RuntimeError> {
975 self.continue_flow_maximally_with(name, &FallbackHandler)
976 }
977
978 /// Run a named flow instance with an external function handler.
979 pub fn continue_flow_maximally_with(
980 &mut self,
981 name: &str,
982 handler: &dyn ExternalFnHandler,
983 ) -> Result<Vec<Step>, RuntimeError> {
984 let (instance, ctx, local) = self
985 .instances
986 .get_mut(name)
987 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
988 let mut view = ContextView::new(ctx, local);
989 let resolver = self.resolver.as_deref();
990 instance.drive_to_terminal::<R>(
991 &self.program,
992 &self.line_tables,
993 &mut view,
994 handler,
995 resolver,
996 )
997 }
998
999 /// Select a choice in a named flow.
1000 pub fn choose_flow(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
1001 let (instance, ctx, local) = self
1002 .instances
1003 .get_mut(name)
1004 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1005 let mut view = ContextView::new(ctx, local);
1006 instance.choose(&mut view, index)
1007 }
1008
1009 /// Destroy a named flow instance — isolated or shared (#200).
1010 pub fn destroy_flow(&mut self, name: &str) -> Result<(), RuntimeError> {
1011 if self.shared_instances.remove(name).is_some() || self.instances.remove(name).is_some() {
1012 Ok(())
1013 } else {
1014 Err(RuntimeError::UnknownFlow(name.to_owned()))
1015 }
1016 }
1017
1018 /// List active flow names (isolated + shared), sorted for determinism.
1019 pub fn flow_names(&self) -> Vec<&str> {
1020 let mut names: Vec<&str> = self
1021 .instances
1022 .keys()
1023 .chain(self.shared_instances.keys())
1024 .map(String::as_str)
1025 .collect();
1026 names.sort_unstable();
1027 names
1028 }
1029
1030 /// Re-evaluate the wake conditions of parked flows and return the ids
1031 /// of the flows that woke, sorted for determinism
1032 /// (`docs/flow-suspension-spec.md` §10.2). Waking never auto-continues:
1033 /// the host drives a woken flow via [`Story::continue_flow_single`] when
1034 /// it wants output.
1035 ///
1036 /// **Returns an empty list until parks exist (FS-3r).** No flow can be
1037 /// parked in today's runtime — the E052 lowering fence keeps `await`
1038 /// from producing bytecode ([`Step::Suspended`] is unreachable), so
1039 /// there are no conditions to re-evaluate. The method ships now (FS-3w)
1040 /// so hosts wire the wake loop against a stable shape; FS-3r fills in
1041 /// real condition evaluation + dirty-tracking without changing this
1042 /// signature. Dirty-tracking is not built here — this is the free stub.
1043 #[must_use]
1044 pub fn wake_check(&mut self) -> Vec<String> {
1045 // FS-3r: iterate parked flows, re-evaluate each dirty condition in
1046 // the owning flow's context via the isolated function-eval
1047 // machinery, collect woken ids. No flow can be parked yet, so the
1048 // woken set is always empty.
1049 Vec::new()
1050 }
1051
1052 // ── Shared flows (#200) ─────────────────────────────────────────
1053 // Spawn a flow that **shares** `default_context` (globals / visit counts /
1054 // rng) with the default flow — true ink concurrent-flow semantics — while
1055 // keeping its own call stack + temps. Distinct from `spawn_flow`, whose
1056 // flows each own an isolated context (bevy-brink's per-entity model).
1057
1058 /// Spawn a shared-context flow at `container_idx` (or the root if `None`).
1059 pub fn spawn_flow_shared(
1060 &mut self,
1061 name: &str,
1062 container_idx: Option<u32>,
1063 ) -> Result<(), RuntimeError> {
1064 // M-2b: same by-id refusal as `spawn_flow` (#803) — a resolved
1065 // `container_idx` (e.g. from `Program::find_address`, as the wasm
1066 // `spawn_flow` binding in `brink-web` does) must not bypass the
1067 // named-lookup refusal either. `None` targets the root, which is
1068 // never private.
1069 if let Some(idx) = container_idx
1070 && self.enforce_visibility
1071 && self.program.has_private_defs()
1072 && self.program.container_is_private(idx)
1073 {
1074 return Err(RuntimeError::PrivateAccess {
1075 name: format!("{}", self.program.container(idx).id),
1076 });
1077 }
1078 if self.shared_instances.contains_key(name) || self.instances.contains_key(name) {
1079 return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
1080 }
1081 // The fresh context the constructor returns is discarded — a shared
1082 // flow runs against `default_context`.
1083 let (mut flow, _ctx) = match container_idx {
1084 Some(idx) => FlowInstance::new_at(&self.program, idx),
1085 None => FlowInstance::new_at_root(&self.program),
1086 };
1087 // Inherit this `Story`'s current enforcement setting — see
1088 // `spawn_flow`'s identical note.
1089 flow.set_visibility_enforcement(self.enforce_visibility);
1090 flow.set_exec_mode(self.exec_mode);
1091 self.shared_instances.insert(name.to_owned(), flow);
1092 Ok(())
1093 }
1094
1095 /// Advance a shared flow one line (against the shared context).
1096 pub fn continue_flow_single(&mut self, name: &str) -> Result<Step, RuntimeError> {
1097 self.continue_flow_single_with(name, &FallbackHandler)
1098 }
1099
1100 /// Advance a shared flow one line with an external-function handler.
1101 pub fn continue_flow_single_with(
1102 &mut self,
1103 name: &str,
1104 handler: &dyn ExternalFnHandler,
1105 ) -> Result<Step, RuntimeError> {
1106 let resolver = self.resolver.as_deref();
1107 let instance = self
1108 .shared_instances
1109 .get_mut(name)
1110 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1111 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
1112 instance.step_single_line::<R>(
1113 &self.program,
1114 &self.line_tables,
1115 &mut view,
1116 handler,
1117 resolver,
1118 )
1119 }
1120
1121 /// Run a shared flow to its next terminal line (against the shared
1122 /// context) — the shared-flow analogue of [`Self::continue_flow_maximally`]
1123 /// (which drives an *isolated* flow instead). Bounded by
1124 /// [`FlowInstance::LINE_LIMIT`] via
1125 /// [`drive_to_terminal`](FlowInstance::drive_to_terminal): an
1126 /// infinite-emitting flow errors with [`RuntimeError::LineLimitExceeded`]
1127 /// rather than growing the returned `Vec` without bound (guard against
1128 /// unbounded growth).
1129 pub fn continue_flow_maximally_shared(
1130 &mut self,
1131 name: &str,
1132 ) -> Result<Vec<Step>, RuntimeError> {
1133 self.continue_flow_maximally_shared_with(name, &FallbackHandler)
1134 }
1135
1136 /// Run a shared flow to its next terminal line with an external-function
1137 /// handler. See [`Self::continue_flow_maximally_shared`].
1138 pub fn continue_flow_maximally_shared_with(
1139 &mut self,
1140 name: &str,
1141 handler: &dyn ExternalFnHandler,
1142 ) -> Result<Vec<Step>, RuntimeError> {
1143 let resolver = self.resolver.as_deref();
1144 let instance = self
1145 .shared_instances
1146 .get_mut(name)
1147 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1148 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
1149 instance.drive_to_terminal::<R>(
1150 &self.program,
1151 &self.line_tables,
1152 &mut view,
1153 handler,
1154 resolver,
1155 )
1156 }
1157
1158 /// Select a choice in a shared flow (against the shared context).
1159 pub fn choose_flow_shared(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
1160 let instance = self
1161 .shared_instances
1162 .get_mut(name)
1163 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1164 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
1165 instance.choose(&mut view, index)
1166 }
1167
1168 /// A structured, name-resolved snapshot of the current runtime state for
1169 /// the studio State View: status, current location, globals, call stack,
1170 /// visit counts, pending choices, and rng. Read-only; built on demand and
1171 /// not on any hot path. See [`DebugSnapshot`](crate::DebugSnapshot).
1172 #[must_use]
1173 pub fn debug_snapshot(&self) -> crate::DebugSnapshot {
1174 self.build_debug_snapshot(&self.default, &self.default_context)
1175 }
1176
1177 /// Read one temp slot in a call frame of the default flow (W16/#3309
1178 /// value editing — the type-check source for an edit). `frame_idx`
1179 /// addresses the SNAPSHOT's `call_stack` ordering — innermost
1180 /// (current) frame first, matching [`DebugFrame`](crate::DebugFrame)
1181 /// — not the raw stack order. `None` when the frame or slot doesn't
1182 /// exist.
1183 #[must_use]
1184 pub fn debug_temp(&self, frame_idx: usize, slot: u16) -> Option<&Value> {
1185 let call_stack = &self.default.flow.current_thread().call_stack;
1186 let depth = call_stack.len();
1187 let stack_idx = depth.checked_sub(1)?.checked_sub(frame_idx)?;
1188 call_stack.temp(stack_idx, slot as usize)
1189 }
1190
1191 /// Set one temp slot in a call frame of the default flow — the
1192 /// set-temp-in-frame debug seam (W16/#3309, RULED: live value editing,
1193 /// paused-only at the studio layer; the runtime itself only requires
1194 /// the frame to exist). Same innermost-first `frame_idx` addressing as
1195 /// [`Self::debug_temp`]. Returns whether the write landed. The slot
1196 /// must already exist (`DeclareTemp` ran) — editing never allocates.
1197 ///
1198 /// Type discipline is the CALLER's job (the wasm boundary parses the
1199 /// author's input against the slot's current type); this seam writes
1200 /// whatever it is given, like the VM's own `SetTemp`.
1201 pub fn debug_set_temp(&mut self, frame_idx: usize, slot: u16, value: Value) -> bool {
1202 let call_stack = &mut self.default.flow.current_thread_mut().call_stack;
1203 let depth = call_stack.len();
1204 let Some(stack_idx) = depth.checked_sub(1).and_then(|d| d.checked_sub(frame_idx)) else {
1205 return false;
1206 };
1207 // The slot must already exist (editing never allocates) — checked
1208 // against the segment directly rather than `is_temp_written`, since
1209 // an edit is legal even on a slot that only exists as `write_temp`'s
1210 // own zero-padding for a not-yet-declared name. A missing frame
1211 // reads as a missing slot.
1212 if call_stack.temp(stack_idx, slot as usize).is_none() {
1213 return false;
1214 }
1215 // Commit through `write_temp` — the single path every real
1216 // temp-slot store in the VM funnels through — so `temps_written`
1217 // is marked exactly like a real `DeclareTemp`/`SetTemp` would.
1218 // Bypassing it (a raw write through `temps.get_mut`) left the bit
1219 // stale, so `Opcode::GetTemp`'s issue #3354 uninitialized-slot gate
1220 // would silently discard this edit on the next read.
1221 call_stack.write_temp(stack_idx, slot as usize, value);
1222 true
1223 }
1224
1225 /// A debug snapshot of a named shared flow (#200), built against the shared
1226 /// `default_context` — so its globals / visit counts match the default
1227 /// flow's, while its call stack + temps are the flow's own. Falls back to a
1228 /// named isolated flow's own context if `name` is one of those instead.
1229 pub fn debug_snapshot_flow(&self, name: &str) -> Result<crate::DebugSnapshot, RuntimeError> {
1230 if let Some(instance) = self.shared_instances.get(name) {
1231 Ok(self.build_debug_snapshot(instance, &self.default_context))
1232 } else if let Some((instance, ctx, _local)) = self.instances.get(name) {
1233 Ok(self.build_debug_snapshot(instance, ctx))
1234 } else {
1235 Err(RuntimeError::UnknownFlow(name.to_owned()))
1236 }
1237 }
1238
1239 /// Build a debug snapshot from a specific flow instance + context. Backs
1240 /// both [`debug_snapshot`](Self::debug_snapshot) and the per-flow variant.
1241 #[expect(
1242 clippy::too_many_lines,
1243 reason = "single-purpose snapshot builder assembling one flat struct \
1244 from several independent, already-small pieces (status, \
1245 location/position, globals, call stack, visit counts, \
1246 pending choices, rng) — splitting would scatter one \
1247 coherent read into several private helpers with no other \
1248 caller, per CLAUDE.md's `cargo fmt`/`clippy` convention \
1249 for this shape"
1250 )]
1251 fn build_debug_snapshot(&self, instance: &FlowInstance, ctx: &World) -> crate::DebugSnapshot {
1252 use alloc::collections::BTreeMap;
1253
1254 use crate::debug::{
1255 DebugChoice, DebugFrame, DebugGlobal, DebugLocal, DebugPosition, DebugRng,
1256 DebugSnapshot, DebugVisit, NameResolver,
1257 };
1258
1259 let flow = &instance.flow;
1260 let resolver = NameResolver::new(&self.program);
1261
1262 let status = match instance.status {
1263 StoryStatus::Active => "active",
1264 StoryStatus::WaitingForChoice => "waiting_for_choice",
1265 StoryStatus::Done => "done",
1266 StoryStatus::Ended => "ended",
1267 };
1268
1269 let thread = flow.current_thread();
1270
1271 // Nearest named container the cursor is currently in (innermost-first)
1272 // — the same derivation as the public `current_path` query.
1273 let stack = &thread.call_stack;
1274 let resolve_frame_location = |depth: usize| frame_path(&self.program, stack, depth);
1275 // Precise `(container_idx, offset)` for a frame: the top of its
1276 // container stack — the next instruction that frame will execute
1277 // (`vm::step` always advances/reads this exact slot; see
1278 // `vm.rs`'s `CallStack::top_container`). `None` for a frame
1279 // whose container stack is already empty.
1280 let frame_position = |depth: usize| {
1281 stack.containers(depth).last().map(|cp| DebugPosition {
1282 container_idx: cp.container_idx,
1283 offset: cp.offset,
1284 })
1285 };
1286
1287 let current_location = stack.top_depth().and_then(resolve_frame_location);
1288 let position = stack.top_depth().and_then(frame_position);
1289
1290 // D7 (`docs/debugger-spec.md` §3, #3185): this frame's named
1291 // locals, resolved via `Program::scope_debug_locals` against the
1292 // frame's *current leaf* container — deliberately NOT by unioning
1293 // `frame.container_stack` (see that method's own doc for why a
1294 // per-container_stack union silently drops an enclosing
1295 // container's locals the moment the leaf moves into a sibling
1296 // child container, even though the call frame's `temps` haven't
1297 // changed at all). `BTreeMap` (not `HashMap`) keeps the merge
1298 // deterministic (`CLAUDE.md` "Determinism matters"), keyed by slot
1299 // so the only known collision case (a future codegen slot reuse —
1300 // see `DebugFrame::locals`'s own doc) resolves to *some* entry
1301 // rather than panicking or reordering nondeterministically. `None`
1302 // when this program carries no `DebugInfo` at all (release-
1303 // exported, or pre-D6) or the frame's container stack is empty
1304 // (nothing left to run in it — no leaf to resolve a scope from).
1305 let resolve_frame_locals = |depth: usize| -> Option<Vec<DebugLocal>> {
1306 self.program.debug_info.as_ref()?;
1307 let leaf = stack.containers(depth).last()?;
1308 let mut by_slot: BTreeMap<u16, DebugLocal> = BTreeMap::new();
1309 for local in self.program.scope_debug_locals(leaf.container_idx) {
1310 if let Some(value) = stack.temp(depth, local.slot as usize) {
1311 by_slot.insert(
1312 local.slot,
1313 DebugLocal {
1314 slot: local.slot,
1315 name: local.name.clone(),
1316 value: resolver.debug_value(value),
1317 synthetic: local.synthetic,
1318 },
1319 );
1320 }
1321 }
1322 Some(by_slot.into_values().collect())
1323 };
1324
1325 // Globals, skipping unnamed slots.
1326 let globals = ctx
1327 .globals
1328 .iter()
1329 .enumerate()
1330 .filter_map(|(i, value)| {
1331 self.program.global_slot_name(i).map(|name| DebugGlobal {
1332 name: name.to_owned(),
1333 value: resolver.format_value(value),
1334 })
1335 })
1336 .collect();
1337
1338 // Call stack, innermost (current) frame first.
1339 let depth = thread.call_stack.len();
1340 let thread_base = Self::thread_base_frame(flow);
1341 let mut call_stack = Vec::with_capacity(depth);
1342 for i in (0..depth).rev() {
1343 if let Some(frame) = thread.call_stack.get(i) {
1344 let kind = if Some(i) == thread_base {
1345 "thread"
1346 } else {
1347 match frame.frame_type {
1348 CallFrameType::Root => "root",
1349 CallFrameType::Function => "function",
1350 CallFrameType::Tunnel => "tunnel",
1351 CallFrameType::External => "external",
1352 CallFrameType::FunctionEvalFromGame => "eval",
1353 }
1354 };
1355 call_stack.push(DebugFrame {
1356 kind,
1357 location: resolve_frame_location(i),
1358 position: frame_position(i),
1359 temps: stack.temps(i).len(),
1360 locals: resolve_frame_locals(i),
1361 });
1362 }
1363 }
1364
1365 // Visit counts, resolved and sorted by path for determinism.
1366 let mut visit_counts: Vec<DebugVisit> = ctx
1367 .visit_counts
1368 .iter()
1369 .filter_map(|(id, &count)| {
1370 resolver.def_path(*id).map(|path| DebugVisit {
1371 path: path.to_owned(),
1372 count,
1373 })
1374 })
1375 .collect();
1376 visit_counts.sort_by(|a, b| a.path.cmp(&b.path));
1377
1378 // Id-keyed visit counts (W11/#3304): EVERY container, anonymous
1379 // choice/gather bodies included — the join surface for the HIR
1380 // overlay projection's `def_id`. Sorted by id for determinism.
1381 let mut visit_ids: Vec<crate::debug::DebugVisitId> = ctx
1382 .visit_counts
1383 .iter()
1384 .map(|(id, &count)| crate::debug::DebugVisitId {
1385 def_id: id.to_string(),
1386 count,
1387 })
1388 .collect();
1389 visit_ids.sort_by(|a, b| a.def_id.cmp(&b.def_id));
1390
1391 // Pending choices: visible texts (resolved) paired with target paths.
1392 let visible_targets: Vec<DefinitionId> = flow
1393 .pending_choices
1394 .iter()
1395 .filter(|pc| !pc.flags.is_invisible_default)
1396 .map(|pc| pc.target_id)
1397 .collect();
1398 let pending_choices = self
1399 .resolved_choices_for(flow)
1400 .into_iter()
1401 .enumerate()
1402 .map(|(i, ch)| DebugChoice {
1403 sticky: ch.sticky,
1404 source: ch.source,
1405 text: ch.text,
1406 target: visible_targets
1407 .get(i)
1408 .and_then(|id| resolver.def_path(*id))
1409 .map(str::to_owned),
1410 // The overlay-projection join key (W11/#3304).
1411 def_id: visible_targets
1412 .get(i)
1413 .map(alloc::string::ToString::to_string)
1414 .unwrap_or_default(),
1415 // `ch.index` is the pre-filter `flow.pending_choices` position
1416 // (see `resolved_choices_for`) — the same index `choose()`
1417 // expects, not the post-filter enumeration position `i`.
1418 index: ch.index,
1419 })
1420 .collect();
1421
1422 DebugSnapshot {
1423 status,
1424 current_location,
1425 position,
1426 turn_index: ctx.turn_index,
1427 globals,
1428 call_stack,
1429 visit_counts,
1430 visit_ids,
1431 pending_choices,
1432 rng: DebugRng {
1433 seed: ctx.rng_seed,
1434 previous: ctx.previous_random,
1435 },
1436 }
1437 }
1438
1439 // ── Session support (crate-internal) ────────────────────────────
1440
1441 /// Whether the default flow is in the `Active` status (mid-turn, more
1442 /// content pending). Used by [`StorySession`](crate::StorySession) for the
1443 /// turn-boundary mutation gate.
1444 pub(crate) fn status_is_active(&self) -> bool {
1445 self.default.status == StoryStatus::Active
1446 }
1447
1448 /// Whether the default flow is waiting for a choice selection. Used by
1449 /// [`StorySession`](crate::StorySession) replay.
1450 pub(crate) fn status_is_waiting_for_choice(&self) -> bool {
1451 self.default.status == StoryStatus::WaitingForChoice
1452 }
1453
1454 /// Build a typed [`StateSnapshot`](crate::StateSnapshot) of the default
1455 /// flow's game state — a NEW typed serialization path (globals with list
1456 /// membership, turn counts, callstack summary), distinct from the
1457 /// string-valued [`DebugSnapshot`](crate::DebugSnapshot).
1458 ///
1459 /// Known projection limit (deliberate, not a silent bug): visit/turn-count
1460 /// entries whose scope has no resolvable author path (anonymous counted
1461 /// containers — gathers, choice points — keyed only by hash id) are
1462 /// **omitted** from the snapshot's path-keyed maps. The full id-keyed
1463 /// counts remain available via [`Story::save_state`].
1464 pub(crate) fn state_snapshot(&self) -> crate::session::StateSnapshot {
1465 use alloc::collections::BTreeMap;
1466
1467 use crate::debug::NameResolver;
1468 use crate::session::{SnapshotFrame, SnapshotList, StateSnapshot};
1469
1470 let flow = &self.default.flow;
1471 let ctx = &self.default_context;
1472 let resolver = NameResolver::new(&self.program);
1473
1474 // Typed globals + resolved list membership.
1475 let mut globals: BTreeMap<String, Value> = BTreeMap::new();
1476 let mut lists: BTreeMap<String, SnapshotList> = BTreeMap::new();
1477 for (i, value) in ctx.globals.iter().enumerate() {
1478 if let Some(name) = self.program.global_slot_name(i) {
1479 if let Value::List(list) = value {
1480 let mut items: Vec<String> = list
1481 .items
1482 .iter()
1483 .filter_map(|id| self.program.list_item_name(*id).map(str::to_owned))
1484 .collect();
1485 items.sort_unstable();
1486 lists.insert(name.to_owned(), SnapshotList { items });
1487 }
1488 globals.insert(name.to_owned(), value.clone());
1489 }
1490 }
1491
1492 // Visit / turn counts by resolved path (deterministic BTreeMap).
1493 let mut visit_counts: BTreeMap<String, u32> = BTreeMap::new();
1494 for (id, &count) in &ctx.visit_counts {
1495 if let Some(path) = resolver.def_path(*id) {
1496 visit_counts.insert(path.to_owned(), count);
1497 }
1498 }
1499 let mut turn_counts: BTreeMap<String, u32> = BTreeMap::new();
1500 for (id, &count) in &ctx.turn_counts {
1501 if let Some(path) = resolver.def_path(*id) {
1502 turn_counts.insert(path.to_owned(), count);
1503 }
1504 }
1505
1506 // Callstack summary, innermost frame first.
1507 let thread = flow.current_thread();
1508 let stack = &thread.call_stack;
1509 let resolve_frame_location = |depth: usize| {
1510 stack
1511 .containers(depth)
1512 .iter()
1513 .rev()
1514 .find_map(|cp| resolver.container_path(cp.container_idx))
1515 .map(str::to_owned)
1516 };
1517 let depth = thread.call_stack.len();
1518 let thread_base = Self::thread_base_frame(flow);
1519 let mut call_stack = Vec::with_capacity(depth);
1520 for i in (0..depth).rev() {
1521 if let Some(frame) = thread.call_stack.get(i) {
1522 let kind = if Some(i) == thread_base {
1523 "thread"
1524 } else {
1525 match frame.frame_type {
1526 CallFrameType::Root => "root",
1527 CallFrameType::Function => "function",
1528 CallFrameType::Tunnel => "tunnel",
1529 CallFrameType::External => "external",
1530 CallFrameType::FunctionEvalFromGame => "eval",
1531 }
1532 };
1533 call_stack.push(SnapshotFrame {
1534 kind: kind.to_owned(),
1535 location: resolve_frame_location(i),
1536 temps: stack.temps(i).len(),
1537 });
1538 }
1539 }
1540
1541 StateSnapshot {
1542 globals,
1543 lists,
1544 turn_index: ctx.turn_index,
1545 visit_counts,
1546 turn_counts,
1547 call_stack,
1548 status: self.default.status.into(),
1549 }
1550 }
1551
1552 // ── Testing / instrumentation API ───────────────────────────────
1553
1554 /// Dump the current execution state for debugging.
1555 ///
1556 /// Returns a human-readable summary of the call stack, current position,
1557 /// value stack, output buffer, globals, and pending choices.
1558 #[cfg(feature = "testing")]
1559 pub fn debug_state(&self) -> String {
1560 use core::fmt::Write;
1561 let mut out = String::new();
1562 let flow = &self.default.flow;
1563 let ctx = &self.default_context;
1564
1565 let _ = writeln!(out, "=== Story Debug State ===");
1566 let _ = writeln!(out, "status: {:?}", self.default.status);
1567
1568 // Current position
1569 let thread = flow.current_thread();
1570 if let Some(cp) = thread.call_stack.top_container() {
1571 let id = self.program.container(cp.container_idx).id;
1572 let _ = writeln!(
1573 out,
1574 "position: container_idx={} id={id:?} offset={}",
1575 cp.container_idx, cp.offset,
1576 );
1577 }
1578
1579 // Call stack
1580 let depth = thread.call_stack.len();
1581 let _ = writeln!(out, "\ncall stack ({depth} frames):");
1582 for i in 0..depth {
1583 if let Some(frame) = thread.call_stack.get(i) {
1584 let ret = frame
1585 .return_address
1586 .map(|r| format!("idx={} off={}", r.container_idx, r.offset));
1587 let _ = writeln!(
1588 out,
1589 " [{i}] {:?} ret={} temps={} containers={}",
1590 frame.frame_type,
1591 ret.as_deref().unwrap_or("none"),
1592 thread.call_stack.temps(i).len(),
1593 thread.call_stack.containers(i).len(),
1594 );
1595 for (j, cp) in thread.call_stack.containers(i).iter().enumerate() {
1596 let id = self.program.container(cp.container_idx).id;
1597 let _ = writeln!(
1598 out,
1599 " container_stack[{j}]: idx={} id={id:?} off={}",
1600 cp.container_idx, cp.offset,
1601 );
1602 }
1603 }
1604 }
1605
1606 // Value stack
1607 let _ = writeln!(out, "\nvalue stack ({}):", flow.value_stack.len());
1608 for (i, v) in flow.value_stack.iter().enumerate() {
1609 let _ = writeln!(out, " [{i}] {v:?}");
1610 }
1611
1612 // Output buffer (unread transcript)
1613 let unread_start = flow.output.cursor;
1614 let transcript = &flow.output.transcript[unread_start..];
1615 let _ = writeln!(
1616 out,
1617 "\noutput buffer (cursor={unread_start}, {} unread parts):",
1618 transcript.len(),
1619 );
1620 for (i, part) in transcript.iter().enumerate() {
1621 let _ = writeln!(out, " [{i}] {part:?}");
1622 }
1623
1624 // Globals
1625 let _ = writeln!(out, "\nglobals:");
1626 for (i, v) in ctx.globals.iter().enumerate() {
1627 #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
1628 if let Some(name) = self.program.global_name(i as u32) {
1629 let _ = writeln!(out, " {name} = {v:?}");
1630 }
1631 }
1632
1633 // Flow flags
1634 let _ = writeln!(out, "\nskipping_choice: {}", flow.skipping_choice);
1635
1636 // Pending choices
1637 let _ = writeln!(out, "\npending choices ({}):", flow.pending_choices.len());
1638 for (i, c) in flow.pending_choices.iter().enumerate() {
1639 let _ = writeln!(out, " [{i}] {:?} -> {:?}", c.display, c.target_id);
1640 }
1641
1642 out
1643 }
1644
1645 /// Returns whether the last execution cycle of the **default** flow
1646 /// ended with a safe exit (explicit `-> DONE` opcode). If false after a
1647 /// `Done` line, the story ran out of content — the next
1648 /// `continue_single` call will return [`RuntimeError::RanOutOfContent`]
1649 /// instead of more text. See [`FlowInstance::did_safe_exit`] for the
1650 /// full contract.
1651 ///
1652 /// This reads only `self.default` — for a named flow (spawned via
1653 /// [`spawn_flow`](Self::spawn_flow) or one of the isolated
1654 /// `instances`), use [`did_safe_exit_flow`](Self::did_safe_exit_flow)
1655 /// instead. Calling this after `continue_flow*` on a named flow
1656 /// silently returns the default flow's stale value.
1657 #[must_use]
1658 pub fn did_safe_exit(&self) -> bool {
1659 self.default.did_safe_exit()
1660 }
1661
1662 /// The knot or `knot.stitch` the default flow is executing in, as the
1663 /// author names it — ink's `state.currentPathString`, without the
1664 /// weave indices. `None` before the first line, after the story ends,
1665 /// or when the position is in no named container. A host that folds
1666 /// lines into speaker runs uses a change here as a scene boundary
1667 /// (#3389 follow-up, ruled 2026-09-02): a divert to another knot ends
1668 /// the run no dialect rule could see.
1669 ///
1670 /// A query, not a per-line field — and, as in ink, it reports where
1671 /// the story IS: after a delivered line the VM already sits at the start
1672 /// of the next content, so the value is the coming line's location. To
1673 /// know where a line comes from, read this BEFORE the continue that
1674 /// delivers it (the first line of a run from the root reads `None`).
1675 /// For a named flow use [`current_path_flow`](Self::current_path_flow).
1676 #[must_use]
1677 pub fn current_path(&self) -> Option<String> {
1678 named_path(self.default.current_path(&self.program))
1679 }
1680
1681 /// Like [`current_path`](Self::current_path), but for a named flow.
1682 pub fn current_path_flow(&self, name: &str) -> Result<Option<String>, RuntimeError> {
1683 if let Some(instance) = self.shared_instances.get(name) {
1684 Ok(named_path(instance.current_path(&self.program)))
1685 } else if let Some((instance, _ctx, _local)) = self.instances.get(name) {
1686 Ok(named_path(instance.current_path(&self.program)))
1687 } else {
1688 Err(RuntimeError::UnknownFlow(name.to_owned()))
1689 }
1690 }
1691
1692 /// Like [`did_safe_exit`](Self::did_safe_exit), but for a named flow
1693 /// (shared or isolated) rather than the default flow. Mirrors
1694 /// [`debug_snapshot_flow`](Self::debug_snapshot_flow)'s lookup shape:
1695 /// checks `shared_instances` first, then falls back to the isolated
1696 /// `instances`.
1697 ///
1698 /// # Errors
1699 /// [`UnknownFlow`](RuntimeError::UnknownFlow) if no flow named `name`
1700 /// exists (shared or isolated).
1701 pub fn did_safe_exit_flow(&self, name: &str) -> Result<bool, RuntimeError> {
1702 if let Some(instance) = self.shared_instances.get(name) {
1703 Ok(instance.did_safe_exit())
1704 } else if let Some((instance, _ctx, _local)) = self.instances.get(name) {
1705 Ok(instance.did_safe_exit())
1706 } else {
1707 Err(RuntimeError::UnknownFlow(name.to_owned()))
1708 }
1709 }
1710
1711 /// Returns whether the last execution cycle passed through an empty
1712 /// choice set (a `Yield` opcode with no pending choices).
1713 #[cfg(feature = "testing")]
1714 pub fn did_unsafe_yield(&self) -> bool {
1715 self.default.flow.did_unsafe_yield
1716 }
1717
1718 /// Execute a single VM step and return a debug trace of what happened.
1719 ///
1720 /// Returns `(opcode_description, container_idx, offset_before)` or None
1721 /// if the step didn't decode an opcode (frame exhaustion, thread completion, etc).
1722 #[cfg(feature = "testing")]
1723 pub fn step_once(&mut self) -> Result<Option<(String, u32, usize)>, RuntimeError> {
1724 use brink_format::Opcode;
1725
1726 let flow = &self.default.flow;
1727 let thread = flow.current_thread();
1728
1729 // Capture position before step
1730 let pre_info = thread.call_stack.top_container().and_then(|pos| {
1731 Some(pos).map(|pos| {
1732 let container = self.program.container(pos.container_idx);
1733 if pos.offset < container.bytecode.len() {
1734 let mut off = pos.offset;
1735 let op = Opcode::decode(&container.bytecode, &mut off).ok();
1736 (pos.container_idx, pos.offset, op)
1737 } else {
1738 (pos.container_idx, pos.offset, None)
1739 }
1740 })
1741 });
1742
1743 // Execute one step
1744 let _result = vm::step::<R>(
1745 &mut self.default.flow,
1746 &self.program,
1747 &self.line_tables,
1748 &mut self.default_context,
1749 &mut self.default.stats,
1750 self.resolver.as_deref(),
1751 )?;
1752
1753 match pre_info {
1754 Some((ci, off, Some(op))) => Ok(Some((format!("{op:?}"), ci, off))),
1755 Some((ci, off, None)) => Ok(Some(("(end of container)".to_string(), ci, off))),
1756 None => Ok(None),
1757 }
1758 }
1759
1760 // ── D8 debugger control seam (issue #3186) ──────────────────────────
1761 //
1762 // Feature-gated per `debug_control`'s own module doc — with
1763 // `debug-hooks` off, none of this exists and nothing below is
1764 // compiled in. Every method here bypasses the buffered line-output
1765 // path (`continue_single` and friends) entirely, stepping `vm::step`
1766 // directly — the same primitive the `testing`-gated `step_once` probe
1767 // above already uses — so a caller sees every opcode boundary, not
1768 // just line boundaries. None of it changes `advance_with_limit` or
1769 // `vm::step_impl`; see `debug_control`'s module doc for the zero-cost
1770 // argument this depends on.
1771
1772 /// Drain every COMPLETED-but-undelivered line from the default flow's
1773 /// output buffer — the exact cursor `continue_single` delivers from
1774 /// (`advance_with_limit` step 1's `take_first_line`). The wasm debug
1775 /// verbs call this before AND after stepping so the two drive roads
1776 /// share ONE delivery stream (W5/#3298): the production line-buffered
1777 /// path runs ahead of what it has handed out, so a line it already
1778 /// completed must surface in the debug outcome exactly once — and,
1779 /// because this consumes the same cursor, never again on a later
1780 /// journaled continue. Suppressed segments are skipped exactly as the
1781 /// production take does; partial (uncompleted) content stays buffered
1782 /// untouched.
1783 #[cfg(feature = "debug-hooks")]
1784 pub fn debug_drain_buffered_lines(&mut self) -> alloc::vec::Vec<DrainedLine> {
1785 let resolver = self.resolver.as_deref();
1786 let mut out = alloc::vec::Vec::new();
1787 while self.default.flow.output.has_completed_line() {
1788 let Some((text, tags, _element, source)) = self.default.flow.output.take_first_line(
1789 &self.program,
1790 &self.line_tables,
1791 resolver,
1792 ) else {
1793 break;
1794 };
1795 out.push((text, tags, source));
1796 }
1797 // Mirror `advance_with_limit`'s step 2: at a yield point no more
1798 // output is coming, so trailing (uncommitted-newline) content is
1799 // flushed — this is how the line before a choice point is
1800 // delivered on the production road, and it must be here too.
1801 if self.default.status != StoryStatus::Active && self.default.flow.output.has_unread() {
1802 let delivered = self.default.flow.line_delivered_this_turn;
1803 for (text, tags, _element, source) in self.default.flow.output.flush_lines_at_yield(
1804 &self.program,
1805 &self.line_tables,
1806 resolver,
1807 delivered,
1808 ) {
1809 out.push((text, tags, source));
1810 }
1811 }
1812 out
1813 }
1814
1815 /// The default flow's current execution position, or `None` when the
1816 /// innermost frame has an empty container stack — mirrors
1817 /// [`debug_snapshot`](Self::debug_snapshot)'s `position` field without
1818 /// building the rest of the snapshot.
1819 #[cfg(feature = "debug-hooks")]
1820 #[must_use]
1821 pub fn debug_position(&self) -> Option<crate::DebugPosition> {
1822 Self::position_of(&self.default.flow)
1823 }
1824
1825 /// [`debug_position`](Self::debug_position) for any flow (#3223):
1826 /// `None` targets the default flow, `Some(name)` a named flow —
1827 /// isolated or shared, the same unified namespace
1828 /// [`destroy_flow`](Self::destroy_flow)/[`flow_names`](Self::flow_names)
1829 /// treat. `Ok(None)` is a real "no position" on a real flow, distinct
1830 /// from the unknown-name error.
1831 ///
1832 /// # Errors
1833 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow.
1834 #[cfg(feature = "debug-hooks")]
1835 pub fn debug_position_flow(
1836 &self,
1837 flow: Option<&str>,
1838 ) -> Result<Option<crate::DebugPosition>, RuntimeError> {
1839 Ok(Self::position_of(&self.debug_flow_ref(flow)?.flow))
1840 }
1841
1842 /// The default flow's current thread's call-stack depth — the raw
1843 /// count [`debug_step`](Self::debug_step)'s step-over/out logic is
1844 /// derived from (`docs/debugger-spec.md` §4).
1845 ///
1846 /// Also available under `testing`, where it is what a bounded-growth
1847 /// regression test reads to assert the call stack does not grow with
1848 /// the turn count (issue #3561).
1849 #[cfg(any(feature = "debug-hooks", feature = "testing"))]
1850 #[must_use]
1851 pub fn debug_call_stack_depth(&self) -> usize {
1852 Self::depth_of(&self.default.flow)
1853 }
1854
1855 /// [`debug_call_stack_depth`](Self::debug_call_stack_depth) for any
1856 /// flow (#3223) — flow selection as in
1857 /// [`debug_position_flow`](Self::debug_position_flow).
1858 ///
1859 /// # Errors
1860 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow.
1861 #[cfg(feature = "debug-hooks")]
1862 pub fn debug_call_stack_depth_flow(&self, flow: Option<&str>) -> Result<usize, RuntimeError> {
1863 Ok(Self::depth_of(&self.debug_flow_ref(flow)?.flow))
1864 }
1865
1866 /// Read-only flow selection for the debug getters (#3223): `None` is
1867 /// the default flow; a name resolves through the isolated map first,
1868 /// then the shared map — the same order [`debug_parts`](Self::debug_parts)
1869 /// uses, and unambiguous because [`spawn_flow`](Self::spawn_flow)/
1870 /// [`spawn_flow_shared`](Self::spawn_flow_shared) refuse a name live in
1871 /// either map.
1872 #[cfg(feature = "debug-hooks")]
1873 fn debug_flow_ref(&self, flow: Option<&str>) -> Result<&FlowInstance, RuntimeError> {
1874 match flow {
1875 None => Ok(&self.default),
1876 Some(name) => self
1877 .instances
1878 .get(name)
1879 .map(|(f, _, _)| f)
1880 .or_else(|| self.shared_instances.get(name))
1881 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned())),
1882 }
1883 }
1884
1885 /// Split this story into the selected flow's mutable [`DebugTarget`]
1886 /// plus the shared read-only [`DebugEnv`] (#3223). A shared flow pairs
1887 /// with `default_context`/`default_local` — its writes are visible to
1888 /// the default flow, true concurrent-flow semantics — while an
1889 /// isolated flow drives its own context, exactly as the production
1890 /// `continue_flow*` methods route each.
1891 #[cfg(feature = "debug-hooks")]
1892 fn debug_parts<'a>(
1893 &'a mut self,
1894 flow: Option<&str>,
1895 handler: &'a dyn ExternalFnHandler,
1896 ) -> Result<(DebugTarget<'a>, DebugEnv<'a>), RuntimeError> {
1897 let Self {
1898 program,
1899 default,
1900 default_context,
1901 default_local,
1902 line_tables,
1903 instances,
1904 shared_instances,
1905 resolver,
1906 ..
1907 } = self;
1908 let target = match flow {
1909 None => DebugTarget {
1910 flow: default,
1911 world: default_context,
1912 local: default_local,
1913 },
1914 Some(name) => {
1915 if let Some((f, w, l)) = instances.get_mut(name) {
1916 DebugTarget {
1917 flow: f,
1918 world: w,
1919 local: l,
1920 }
1921 } else if let Some(f) = shared_instances.get_mut(name) {
1922 DebugTarget {
1923 flow: f,
1924 world: default_context,
1925 local: default_local,
1926 }
1927 } else {
1928 return Err(RuntimeError::UnknownFlow(name.to_owned()));
1929 }
1930 }
1931 };
1932 Ok((
1933 target,
1934 DebugEnv {
1935 program,
1936 line_tables,
1937 resolver: resolver.as_deref(),
1938 handler,
1939 },
1940 ))
1941 }
1942
1943 /// Run the default flow forward one VM instruction at a time until an
1944 /// enabled breakpoint in `breakpoints` is reached — checked *before*
1945 /// the matching instruction executes, so execution halts BEFORE it
1946 /// runs, not after — or the flow reaches a stopping VM outcome (a
1947 /// choice point or a terminal `-> DONE`/`-> END`).
1948 ///
1949 /// The breakpoint check is skipped on this call's very first
1950 /// iteration, before any `vm::step` has run — otherwise a resumed
1951 /// `debug_run` called right after a previous `debug_run`/`debug_step`
1952 /// stopped exactly on an armed breakpoint would immediately re-report
1953 /// that same breakpoint without making any forward progress, forever
1954 /// (issue #3186 review: "resume is impossible"). At least one
1955 /// instruction always executes before a breakpoint at the position
1956 /// already stopped at is honored again.
1957 ///
1958 /// A choice point (`-> DONE`/exhaustion with pending choices) reports
1959 /// [`DebugStopReason::Choices`](crate::DebugStopReason::Choices), not
1960 /// [`DebugStopReason::Terminal`](crate::DebugStopReason::Terminal) —
1961 /// distinguishing the two matters because
1962 /// [`Story::choose`](Self::choose) only accepts the former. The same
1963 /// turn-index bump and invisible-default auto-select the production
1964 /// per-turn loop performs on this outcome are applied here too, via
1965 /// [`flow_instance::apply_done_bookkeeping`], so `status` and
1966 /// `turn_index` never diverge from what a production-path caller would
1967 /// see (issue #3186 review: "turn boundaries are mislabeled").
1968 ///
1969 /// Bounded by `budget_ceiling` VM steps — **not** the production step
1970 /// limit, and this never reads or writes `Stats::steps` (the counter
1971 /// `advance_with_limit`'s own step-limit check reads); the debug
1972 /// budget is tracked in a loop-local variable instead. See
1973 /// `debug_control`'s module doc for the full accounting argument
1974 /// (2026-08-28 step-limit ruling on issue #3186). Pass
1975 /// [`crate::DEFAULT_DEBUG_BUDGET`] unless the caller has a reason to
1976 /// override it.
1977 ///
1978 /// # Errors
1979 /// [`RuntimeError::DebugBudgetExceeded`] if `budget_ceiling` VM steps
1980 /// pass without hitting a breakpoint or a stopping outcome — never
1981 /// [`RuntimeError::StepLimitExceeded`], which is the *production*
1982 /// step-limit error and would misreport which budget fired. Any other
1983 /// error `vm::step` itself can produce.
1984 ///
1985 /// An `EXTERNAL` call crossed mid-run resolves exactly as production
1986 /// `advance()` resolves it (#3224): this method binds the
1987 /// [`FallbackHandler`], so the in-story fallback body runs; a host
1988 /// with real bindings passes its handler to
1989 /// [`debug_run_flow`](Self::debug_run_flow), and a handler that
1990 /// defers ([`ExternalResult::Pending`](crate::ExternalResult::Pending))
1991 /// parks the run with
1992 /// [`DebugStopReason::AwaitingExternal`](crate::DebugStopReason::AwaitingExternal)
1993 /// — frame intact, resolve out-of-band, then resume.
1994 #[cfg(feature = "debug-hooks")]
1995 pub fn debug_run(
1996 &mut self,
1997 breakpoints: &crate::debug_control::BreakpointSet,
1998 budget_ceiling: u64,
1999 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2000 self.debug_run_flow(None, &FallbackHandler, breakpoints, budget_ceiling)
2001 }
2002
2003 /// Like [`debug_run`](Self::debug_run), but ALSO stops — with
2004 /// [`DebugStopReason::Step`](crate::DebugStopReason::Step) — the
2005 /// moment the flow's output buffer holds a **completed line**: the
2006 /// granularity ladder's top tier (2026-08-30 Continue ruling,
2007 /// `docs/decision-log.md`), "advance until the next content line is
2008 /// delivered". The stop lands strictly *past* the line's commit
2009 /// boundary (a line only completes once the following non-whitespace
2010 /// output begins, because glue may still legally join onto it — the
2011 /// same `has_completed_line` rule the production delivery cursor
2012 /// obeys), so a caller that drains
2013 /// [`debug_drain_buffered_lines`](Self::debug_drain_buffered_lines)
2014 /// after this verb receives the crossed line IN this stop's outcome —
2015 /// no one-advance delivery lag (#3321's felt half). Breakpoints,
2016 /// choice points, terminals, and deferred externals stop it early,
2017 /// exactly as in [`debug_run`](Self::debug_run); a line completed by
2018 /// the *flush* at a yield point surfaces through those stops' drain
2019 /// instead (the production road's own delivery for a line before a
2020 /// choice). Requires no debug line info — the stop condition is
2021 /// output-buffer state, not a `DebugInfo` entry.
2022 ///
2023 /// # Errors
2024 /// As [`debug_run`](Self::debug_run).
2025 #[cfg(feature = "debug-hooks")]
2026 pub fn debug_run_to_line(
2027 &mut self,
2028 breakpoints: &crate::debug_control::BreakpointSet,
2029 budget_ceiling: u64,
2030 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2031 self.debug_run_to_line_flow(None, &FallbackHandler, breakpoints, budget_ceiling)
2032 }
2033
2034 /// [`debug_run_to_line`](Self::debug_run_to_line) for any flow —
2035 /// flow selection as in [`debug_run_flow`](Self::debug_run_flow).
2036 ///
2037 /// # Errors
2038 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow; then
2039 /// everything [`debug_run`](Self::debug_run) can raise.
2040 #[cfg(feature = "debug-hooks")]
2041 pub fn debug_run_to_line_flow(
2042 &mut self,
2043 flow: Option<&str>,
2044 handler: &dyn ExternalFnHandler,
2045 breakpoints: &crate::debug_control::BreakpointSet,
2046 budget_ceiling: u64,
2047 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2048 let (target, env) = self.debug_parts(flow, handler)?;
2049 Self::debug_run_impl(
2050 &env,
2051 target,
2052 breakpoints,
2053 budget_ceiling,
2054 StopOnLine::Yes,
2055 None,
2056 )
2057 }
2058
2059 /// [`debug_run`](Self::debug_run) for any flow (#3223): `None`
2060 /// targets the default flow (identical to `debug_run`), `Some(name)`
2061 /// a named flow — isolated or shared. A shared flow's writes land in
2062 /// the default context, so a debug session on one is observable from
2063 /// the others, exactly as in production.
2064 ///
2065 /// # Errors
2066 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow; then
2067 /// everything [`debug_run`](Self::debug_run) can raise.
2068 #[cfg(feature = "debug-hooks")]
2069 pub fn debug_run_flow(
2070 &mut self,
2071 flow: Option<&str>,
2072 handler: &dyn ExternalFnHandler,
2073 breakpoints: &crate::debug_control::BreakpointSet,
2074 budget_ceiling: u64,
2075 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2076 let (target, env) = self.debug_parts(flow, handler)?;
2077 Self::debug_run_impl(
2078 &env,
2079 target,
2080 breakpoints,
2081 budget_ceiling,
2082 StopOnLine::No,
2083 None,
2084 )
2085 }
2086
2087 /// What one `vm::step` outcome means for a debug loop (#3224):
2088 /// keep looping, keep looping but the step crossed (and resolved) an
2089 /// `External` call — `debug_step_impl` flips `Into` to `Over`
2090 /// semantics on that signal, spec §4 — or stop with an outcome.
2091 #[cfg(feature = "debug-hooks")]
2092 fn debug_handle_stepped(
2093 stepped: vm::Stepped,
2094 env: &DebugEnv<'_>,
2095 flow: &mut FlowInstance,
2096 view: &mut ContextView<'_>,
2097 ) -> Result<SteppedDisposition, RuntimeError> {
2098 use crate::debug_control::DebugStopReason;
2099 let stop = |flow: &FlowInstance, reason| {
2100 SteppedDisposition::Stop(crate::DebugRunOutcome {
2101 reason,
2102 position: Self::position_of(&flow.flow),
2103 depth: Self::depth_of(&flow.flow),
2104 })
2105 };
2106 Ok(match stepped {
2107 vm::Stepped::Done => match flow_instance::apply_done_bookkeeping(
2108 &mut flow.flow,
2109 view,
2110 &mut flow.status,
2111 &mut flow.stats,
2112 )? {
2113 flow_instance::DoneBookkeeping::AutoSelected => SteppedDisposition::Resumed,
2114 flow_instance::DoneBookkeeping::WaitingForChoice => {
2115 stop(flow, DebugStopReason::Choices)
2116 }
2117 flow_instance::DoneBookkeeping::Terminal => stop(flow, DebugStopReason::Terminal),
2118 },
2119 vm::Stepped::Ended => {
2120 view.increment_turn_index();
2121 flow.status = StoryStatus::Ended;
2122 stop(flow, DebugStopReason::Terminal)
2123 }
2124 vm::Stepped::ExternalCall => {
2125 // #3224: resolve through the SAME function production
2126 // `advance()` uses, so debug and production stepping can
2127 // never disagree about binding semantics. An unresolved
2128 // (deferred) external parks with the frame intact for
2129 // out-of-band resolution.
2130 if flow_instance::resolve_external_call(&mut flow.flow, env.program, env.handler)? {
2131 SteppedDisposition::CrossedExternal
2132 } else {
2133 stop(flow, DebugStopReason::AwaitingExternal)
2134 }
2135 }
2136 vm::Stepped::Continue | vm::Stepped::ThreadCompleted => SteppedDisposition::Continue,
2137 })
2138 }
2139
2140 /// The unified debug run loop. `watchpoints` (W18/#3311) threads the
2141 /// [`WriteObserver`] seam through every step when present — the same
2142 /// composition `debug_run_watching` always had, now shared with the
2143 /// run-to-line tier so the Player's Continue honors data breakpoints
2144 /// too. `None` steps unobserved (identical codegen path to before).
2145 #[cfg(feature = "debug-hooks")]
2146 fn debug_run_impl(
2147 env: &DebugEnv<'_>,
2148 target: DebugTarget<'_>,
2149 breakpoints: &crate::debug_control::BreakpointSet,
2150 budget_ceiling: u64,
2151 stop_on_line: StopOnLine,
2152 mut watchpoints: Option<&mut crate::WatchpointObserver>,
2153 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2154 use crate::debug_control::DebugStopReason;
2155 use crate::state::ObservedContext;
2156
2157 let DebugTarget { flow, world, local } = target;
2158 let mut steps: u64 = 0;
2159 let mut past_entry = false;
2160 loop {
2161 // Leftover-hit drain (#3226), BEFORE any stepping: a hit
2162 // already queued in the observer reports HERE, at the position
2163 // it was queued at, instead of being attributed to whatever
2164 // instruction the next step happens to execute. (One VM step
2165 // queues at most one hit today — the VM's two `set_global`
2166 // sites are single-write opcodes, pinned by
2167 // `one_step_never_queues_a_second_watchpoint_hit` — but the
2168 // loop-top drain makes that an optimization detail, not a
2169 // correctness dependency.)
2170 if let Some(w) = watchpoints.as_deref_mut()
2171 && let Some(hit) = w.take_hit()
2172 {
2173 return Ok(crate::DebugRunOutcome {
2174 reason: DebugStopReason::Watchpoint {
2175 global_idx: hit.global_idx,
2176 },
2177 position: Self::position_of(&flow.flow),
2178 depth: Self::depth_of(&flow.flow),
2179 });
2180 }
2181 if past_entry
2182 && let Some(pos) = Self::position_of(&flow.flow)
2183 && let Some(bp) = breakpoints.hit(pos)
2184 {
2185 return Ok(crate::DebugRunOutcome {
2186 reason: DebugStopReason::Breakpoint {
2187 id: bp.id,
2188 name: bp.name.clone(),
2189 },
2190 position: Some(pos),
2191 depth: Self::depth_of(&flow.flow),
2192 });
2193 }
2194 past_entry = true;
2195 steps += 1;
2196 if steps > budget_ceiling {
2197 return Err(RuntimeError::DebugBudgetExceeded {
2198 breakpoint: "run".to_owned(),
2199 ceiling: budget_ceiling,
2200 });
2201 }
2202
2203 let stepped = if let Some(w) = watchpoints.as_deref_mut() {
2204 let mut view = ContextView::new(&mut *world, &mut *local);
2205 let mut obs_ctx = ObservedContext::new(&mut view, w);
2206 vm::step::<R>(
2207 &mut flow.flow,
2208 env.program,
2209 env.line_tables,
2210 &mut obs_ctx,
2211 &mut flow.stats,
2212 env.resolver,
2213 )?
2214 } else {
2215 let mut view = ContextView::new(&mut *world, &mut *local);
2216 vm::step::<R>(
2217 &mut flow.flow,
2218 env.program,
2219 env.line_tables,
2220 &mut view,
2221 &mut flow.stats,
2222 env.resolver,
2223 )?
2224 };
2225
2226 if let Some(w) = watchpoints.as_deref_mut()
2227 && let Some(hit) = w.take_hit()
2228 {
2229 return Ok(crate::DebugRunOutcome {
2230 reason: DebugStopReason::Watchpoint {
2231 global_idx: hit.global_idx,
2232 },
2233 position: Self::position_of(&flow.flow),
2234 depth: Self::depth_of(&flow.flow),
2235 });
2236 }
2237
2238 // A fresh (unobserved) view for the bookkeeping half —
2239 // bookkeeping writes are not watchable state.
2240 let mut view = ContextView::new(&mut *world, &mut *local);
2241 match Self::debug_handle_stepped(stepped, env, flow, &mut view)? {
2242 SteppedDisposition::Continue
2243 | SteppedDisposition::Resumed
2244 | SteppedDisposition::CrossedExternal => {}
2245 SteppedDisposition::Stop(outcome) => return Ok(outcome),
2246 }
2247
2248 // The run-to-line tier (2026-08-30 Continue ruling): a line
2249 // COMMITTING is the stop condition — checked after the step so
2250 // the stop lands past the commit boundary and the line is
2251 // drainable at the stop, not one advance later (#3321).
2252 if stop_on_line == StopOnLine::Yes && flow.flow.output.has_completed_line() {
2253 return Ok(crate::DebugRunOutcome {
2254 reason: DebugStopReason::Step,
2255 position: Self::position_of(&flow.flow),
2256 depth: Self::depth_of(&flow.flow),
2257 });
2258 }
2259 }
2260 }
2261
2262 /// Like [`debug_run`](Self::debug_run), but writes are routed through
2263 /// `watchpoints` (a [`crate::WatchpointObserver`]) via the existing
2264 /// [`ObservedContext`](crate::ObservedContext) seam — reusing
2265 /// [`WriteObserver`] rather than a second observer mechanism, exactly
2266 /// as `continue_single_observed` already does for the buffered
2267 /// production path. Also stops, with
2268 /// [`DebugStopReason::Watchpoint`](crate::DebugStopReason::Watchpoint),
2269 /// the moment a watched global is written, in addition to every
2270 /// `debug_run` stop condition.
2271 ///
2272 /// Drain contract (#3226): one stop per hit, each attributed to its
2273 /// own writing instruction. A hit already queued when this is called
2274 /// — the observer doubles as a non-pausing logger on the production
2275 /// path, so leftovers are a real state — reports immediately at the
2276 /// current position, before any stepping. (One VM step can queue at
2277 /// most one hit today — the VM's two `set_global` sites are
2278 /// single-write opcodes — but the loop-top drain makes that an
2279 /// optimization detail, not a correctness dependency.)
2280 ///
2281 /// # Errors
2282 /// Same as [`debug_run`](Self::debug_run).
2283 #[cfg(feature = "debug-hooks")]
2284 pub fn debug_run_watching(
2285 &mut self,
2286 breakpoints: &crate::debug_control::BreakpointSet,
2287 watchpoints: &mut crate::WatchpointObserver,
2288 budget_ceiling: u64,
2289 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2290 self.debug_run_watching_flow(
2291 None,
2292 &FallbackHandler,
2293 breakpoints,
2294 watchpoints,
2295 budget_ceiling,
2296 )
2297 }
2298
2299 /// [`debug_run_watching`](Self::debug_run_watching) for any flow
2300 /// (#3223) — flow selection as in
2301 /// [`debug_run_flow`](Self::debug_run_flow). Note the watch surface is
2302 /// the *context* the flow routes through: on a shared flow the watched
2303 /// globals live in the default context, so a hit can be caused by the
2304 /// debugged flow only (this seam steps no other flow concurrently).
2305 ///
2306 /// # Errors
2307 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow; then
2308 /// everything [`debug_run`](Self::debug_run) can raise.
2309 #[cfg(feature = "debug-hooks")]
2310 pub fn debug_run_watching_flow(
2311 &mut self,
2312 flow: Option<&str>,
2313 handler: &dyn ExternalFnHandler,
2314 breakpoints: &crate::debug_control::BreakpointSet,
2315 watchpoints: &mut crate::WatchpointObserver,
2316 budget_ceiling: u64,
2317 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2318 let (target, env) = self.debug_parts(flow, handler)?;
2319 Self::debug_run_impl(
2320 &env,
2321 target,
2322 breakpoints,
2323 budget_ceiling,
2324 StopOnLine::No,
2325 Some(watchpoints),
2326 )
2327 }
2328
2329 /// [`debug_run_to_line`](Self::debug_run_to_line) with writes routed
2330 /// through `watchpoints` (W18/#3311) — the Player's Continue tier
2331 /// honoring data breakpoints: stops on a watched write, an armed
2332 /// breakpoint, OR the next committed content line, whichever first.
2333 /// Same drain contract as [`debug_run_watching`](Self::debug_run_watching).
2334 ///
2335 /// # Errors
2336 /// Same as [`debug_run`](Self::debug_run).
2337 #[cfg(feature = "debug-hooks")]
2338 pub fn debug_run_to_line_watching(
2339 &mut self,
2340 breakpoints: &crate::debug_control::BreakpointSet,
2341 watchpoints: &mut crate::WatchpointObserver,
2342 budget_ceiling: u64,
2343 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2344 self.debug_run_to_line_watching_flow(
2345 None,
2346 &FallbackHandler,
2347 breakpoints,
2348 watchpoints,
2349 budget_ceiling,
2350 )
2351 }
2352
2353 /// [`debug_run_to_line_watching`](Self::debug_run_to_line_watching)
2354 /// for any flow — flow selection as in
2355 /// [`debug_run_flow`](Self::debug_run_flow); the watch surface is the
2356 /// context the flow routes through, as in
2357 /// [`debug_run_watching_flow`](Self::debug_run_watching_flow).
2358 ///
2359 /// # Errors
2360 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow; then
2361 /// everything [`debug_run`](Self::debug_run) can raise.
2362 #[cfg(feature = "debug-hooks")]
2363 pub fn debug_run_to_line_watching_flow(
2364 &mut self,
2365 flow: Option<&str>,
2366 handler: &dyn ExternalFnHandler,
2367 breakpoints: &crate::debug_control::BreakpointSet,
2368 watchpoints: &mut crate::WatchpointObserver,
2369 budget_ceiling: u64,
2370 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2371 let (target, env) = self.debug_parts(flow, handler)?;
2372 Self::debug_run_impl(
2373 &env,
2374 target,
2375 breakpoints,
2376 budget_ceiling,
2377 StopOnLine::Yes,
2378 Some(watchpoints),
2379 )
2380 }
2381
2382 /// Step the default flow by one [`StepMode`](crate::StepMode) unit,
2383 /// derived from call-stack depth deltas (`docs/debugger-spec.md` §4):
2384 ///
2385 /// - [`StepMode::Into`](crate::StepMode::Into): execute exactly one
2386 /// instruction, descending into any newly-entered frame.
2387 /// - [`StepMode::Over`](crate::StepMode::Over): execute instructions
2388 /// until back at (or still at) the starting depth — runs through any
2389 /// call the first instruction makes without stopping inside it.
2390 /// - [`StepMode::Out`](crate::StepMode::Out): execute instructions
2391 /// until the current frame returns to its caller (depth strictly
2392 /// less than the starting depth). Refused up front, with
2393 /// [`DebugStopReason::NoStepOutTarget`](crate::DebugStopReason::NoStepOutTarget)
2394 /// and no VM stepping at all, when the starting depth is the
2395 /// outermost (`Root`) frame — §4: "The debugger must disable
2396 /// step-out... exactly as GDB disables `finish` in the outermost
2397 /// frame" — **or** when the innermost frame is a
2398 /// [`CallFrameType::Thread`]: §4's ruled `Thread` row ("a thread is
2399 /// not a frame you can return from... must not offer step out as if
2400 /// it returns anywhere", decision-log D1 entry item 11) applies the
2401 /// same refusal for the same reason — a thread exhausting just pops
2402 /// it (`vm::step`'s `Opcode::Done`/`Yield` handling), which is not a
2403 /// return to a caller and must not be reported as `Step`.
2404 ///
2405 /// `breakpoints` is checked on every iteration after the first (same
2406 /// "skip the entry position" rule [`debug_run`](Self::debug_run)
2407 /// documents) — an armed breakpoint reached partway through a
2408 /// `StepMode::Over`/`Out` run halts the step early, before the
2409 /// matching instruction executes, exactly as it would inside
2410 /// `debug_run`. A `StepMode::Into` step always stops after its own
2411 /// single instruction, so it never reaches a second iteration where a
2412 /// breakpoint could fire mid-step.
2413 ///
2414 /// A choice point reached mid-step reports
2415 /// [`DebugStopReason::Choices`](crate::DebugStopReason::Choices) (with
2416 /// the same turn-index/auto-select bookkeeping
2417 /// [`debug_run`](Self::debug_run) applies), taking priority over the
2418 /// requested step's own stop condition — see `debug_run`'s doc.
2419 ///
2420 /// Bounded by `budget_ceiling` VM steps on the same terms as
2421 /// [`debug_run`](Self::debug_run) — never touches `Stats::steps`.
2422 ///
2423 /// # Errors
2424 /// [`RuntimeError::DebugBudgetExceeded`] if the step target is never
2425 /// reached within `budget_ceiling` VM steps (a `StepMode::Over`/`Out`
2426 /// whose target frame never returns — e.g. a runaway loop between
2427 /// entering and leaving it). Any other error `vm::step` itself can
2428 /// produce.
2429 #[cfg(feature = "debug-hooks")]
2430 pub fn debug_step(
2431 &mut self,
2432 mode: crate::debug_control::StepMode,
2433 breakpoints: &crate::debug_control::BreakpointSet,
2434 budget_ceiling: u64,
2435 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2436 self.debug_step_flow(None, &FallbackHandler, mode, breakpoints, budget_ceiling)
2437 }
2438
2439 /// [`debug_step`](Self::debug_step) for any flow (#3223) — flow
2440 /// selection as in [`debug_run_flow`](Self::debug_run_flow).
2441 ///
2442 /// # Errors
2443 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow; then
2444 /// everything [`debug_step`](Self::debug_step) can raise.
2445 #[cfg(feature = "debug-hooks")]
2446 pub fn debug_step_flow(
2447 &mut self,
2448 flow: Option<&str>,
2449 handler: &dyn ExternalFnHandler,
2450 mode: crate::debug_control::StepMode,
2451 breakpoints: &crate::debug_control::BreakpointSet,
2452 budget_ceiling: u64,
2453 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2454 let (target, env) = self.debug_parts(flow, handler)?;
2455 Self::debug_step_impl(
2456 &env,
2457 target,
2458 mode,
2459 StepGranularity::Instruction,
2460 breakpoints,
2461 budget_ceiling,
2462 )
2463 }
2464
2465 /// Advance to the next **source line** (#3264), the granularity every
2466 /// GDB-style debugger means by `step`/`next`/`finish`.
2467 ///
2468 /// Both granularities are first-class (RULED 2026-08-28): the studio
2469 /// presents the `.inkt` disassembly beside the source, so an author can
2470 /// watch a line and the instructions it became at the same time. This
2471 /// is not a replacement for [`Self::debug_step`] — it is the other verb.
2472 ///
2473 /// Implemented as the *same* loop with one more stop condition, not as
2474 /// a loop calling `debug_step`. That matters for the budget: nesting
2475 /// would let each inner step spend the full ceiling, so the real
2476 /// worst-case cost would be the ceiling squared. Here one budget
2477 /// governs the whole line step, and exceeding it reports
2478 /// `DebugBudgetExceeded` exactly as instruction stepping does — a line
2479 /// that never changes (a tight loop) cannot hang.
2480 ///
2481 /// Per mode:
2482 /// - **Into** stops at the first instruction on a different line,
2483 /// whatever the depth — descending into a call lands on the callee's
2484 /// first line, which is what "step into" means.
2485 /// - **Over** additionally requires the depth to be back at or below
2486 /// where it started, so a call runs to completion instead of stopping
2487 /// inside it.
2488 /// - **Out** is identical to its instruction form and deliberately does
2489 /// NOT wait for a line change: returning lands mid-line at the call
2490 /// site, which is exactly where GDB's `finish` stops. Requiring a
2491 /// line change here would overshoot into the following line.
2492 ///
2493 /// Returns [`DebugStopReason::NoLineInfo`] when the artifact cannot say
2494 /// which line execution is on, rather than quietly behaving like
2495 /// [`Self::debug_step`].
2496 #[cfg(feature = "debug-hooks")]
2497 pub fn debug_step_line(
2498 &mut self,
2499 mode: crate::debug_control::StepMode,
2500 breakpoints: &crate::debug_control::BreakpointSet,
2501 budget_ceiling: u64,
2502 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2503 self.debug_step_line_flow(None, &FallbackHandler, mode, breakpoints, budget_ceiling)
2504 }
2505
2506 /// [`debug_step_line`](Self::debug_step_line) for any flow (#3223) —
2507 /// flow selection as in [`debug_run_flow`](Self::debug_run_flow).
2508 ///
2509 /// # Errors
2510 /// [`RuntimeError::UnknownFlow`] if `flow` names no live flow; then
2511 /// everything [`debug_step_line`](Self::debug_step_line) can raise.
2512 #[cfg(feature = "debug-hooks")]
2513 pub fn debug_step_line_flow(
2514 &mut self,
2515 flow: Option<&str>,
2516 handler: &dyn ExternalFnHandler,
2517 mode: crate::debug_control::StepMode,
2518 breakpoints: &crate::debug_control::BreakpointSet,
2519 budget_ceiling: u64,
2520 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2521 let (target, env) = self.debug_parts(flow, handler)?;
2522 Self::debug_step_impl(
2523 &env,
2524 target,
2525 mode,
2526 StepGranularity::Line,
2527 breakpoints,
2528 budget_ceiling,
2529 )
2530 }
2531
2532 #[cfg(feature = "debug-hooks")]
2533 fn debug_step_impl(
2534 env: &DebugEnv<'_>,
2535 target: DebugTarget<'_>,
2536 mode: crate::debug_control::StepMode,
2537 granularity: StepGranularity,
2538 breakpoints: &crate::debug_control::BreakpointSet,
2539 budget_ceiling: u64,
2540 ) -> Result<crate::DebugRunOutcome, RuntimeError> {
2541 use crate::debug_control::{DebugStopReason, StepMode};
2542
2543 let DebugTarget { flow, world, local } = target;
2544 let depth_before = Self::depth_of(&flow.flow);
2545 let line_before =
2546 match Self::line_before(env.program, &flow.flow, granularity, depth_before) {
2547 Ok(l) => l,
2548 Err(outcome) => return Ok(outcome),
2549 };
2550 if let Some(outcome) = Self::no_step_out_target(&flow.flow, mode, depth_before) {
2551 return Ok(outcome);
2552 }
2553
2554 let mut view = ContextView::new(world, local);
2555 let mut steps: u64 = 0;
2556 let mut past_entry = false;
2557 // #3224, spec §4: a call that pushes an `External` frame is opaque
2558 // — no ink bytecode inside — so once one is crossed, an `Into`
2559 // step adopts `Over`'s stop conditions (run until back at the
2560 // starting depth) instead of stopping after its single
2561 // instruction, which would strand the debugger inside a frame it
2562 // cannot step through (or, with an in-story fallback, inside the
2563 // fallback body `Into` was never asked to enter).
2564 let mut crossed_external = false;
2565 loop {
2566 if past_entry
2567 && let Some(pos) = Self::position_of(&flow.flow)
2568 && let Some(bp) = breakpoints.hit(pos)
2569 {
2570 return Ok(crate::DebugRunOutcome {
2571 reason: DebugStopReason::Breakpoint {
2572 id: bp.id,
2573 name: bp.name.clone(),
2574 },
2575 position: Some(pos),
2576 depth: Self::depth_of(&flow.flow),
2577 });
2578 }
2579 past_entry = true;
2580 steps += 1;
2581 if steps > budget_ceiling {
2582 return Err(RuntimeError::DebugBudgetExceeded {
2583 breakpoint: "step".to_owned(),
2584 ceiling: budget_ceiling,
2585 });
2586 }
2587
2588 let stepped = vm::step::<R>(
2589 &mut flow.flow,
2590 env.program,
2591 env.line_tables,
2592 &mut view,
2593 &mut flow.stats,
2594 env.resolver,
2595 )?;
2596
2597 match Self::debug_handle_stepped(stepped, env, flow, &mut view)? {
2598 SteppedDisposition::Continue => {}
2599 SteppedDisposition::Resumed => continue,
2600 SteppedDisposition::CrossedExternal => crossed_external = true,
2601 SteppedDisposition::Stop(outcome) => return Ok(outcome),
2602 }
2603
2604 let effective_mode = if crossed_external && mode == StepMode::Into {
2605 StepMode::Over
2606 } else {
2607 mode
2608 };
2609 let depth_after = Self::depth_of(&flow.flow);
2610 let depth_ok = match effective_mode {
2611 StepMode::Into => true,
2612 StepMode::Over => depth_after <= depth_before,
2613 StepMode::Out => depth_after < depth_before,
2614 };
2615 // `Out` is the same verb at both granularities: returning lands
2616 // mid-line at the call site, which is where `finish` stops.
2617 let line_ok = match (granularity, effective_mode) {
2618 (StepGranularity::Instruction, _) | (StepGranularity::Line, StepMode::Out) => true,
2619 (StepGranularity::Line, _) => Self::line_key_of(&flow.flow, env.program)
2620 .is_some_and(|now| Some(now) != line_before),
2621 };
2622 let stop = depth_ok && line_ok;
2623 if stop {
2624 let position = Self::position_of(&flow.flow);
2625 // A step that LANDS on an armed breakpoint reports the
2626 // BREAKPOINT, not the step (GDB's own behavior). Reporting
2627 // `Step` here made the breakpoint silently unhittable by
2628 // line-stepping (found live in the W5 studio review): the
2629 // next advance resumes FROM this address, where the
2630 // past-entry rule rightly skips it — so no call ever got
2631 // to claim the hit.
2632 if let Some(pos) = position
2633 && let Some(bp) = breakpoints.hit(pos)
2634 {
2635 return Ok(crate::DebugRunOutcome {
2636 reason: DebugStopReason::Breakpoint {
2637 id: bp.id,
2638 name: bp.name.clone(),
2639 },
2640 position,
2641 depth: depth_after,
2642 });
2643 }
2644 return Ok(crate::DebugRunOutcome {
2645 reason: DebugStopReason::Step,
2646 position,
2647 depth: depth_after,
2648 });
2649 }
2650 }
2651 }
2652
2653 /// The `NoStepOutTarget` outcome when `Out` has nowhere to go — the
2654 /// outermost frame, or a `Thread` frame, which is not returnable-from
2655 /// (`docs/debugger-spec.md` names threads as a genuine non-analogue to
2656 /// GDB's frames). `None` for every other mode, and for any frame that
2657 /// can actually return.
2658 #[cfg(feature = "debug-hooks")]
2659 fn no_step_out_target(
2660 flow: &Flow,
2661 mode: crate::debug_control::StepMode,
2662 depth_before: usize,
2663 ) -> Option<crate::DebugRunOutcome> {
2664 use crate::debug_control::{DebugStopReason, StepMode};
2665 if mode != StepMode::Out {
2666 return None;
2667 }
2668 let innermost_is_thread = flow.at_thread_base();
2669 (depth_before <= 1 || innermost_is_thread).then(|| crate::DebugRunOutcome {
2670 reason: DebugStopReason::NoStepOutTarget,
2671 position: Self::position_of(flow),
2672 depth: depth_before,
2673 })
2674 }
2675
2676 /// The line a `Line`-granular step starts from, captured BEFORE any
2677 /// stepping so "a different line" is measured from where the user was
2678 /// rather than from wherever the first instruction landed.
2679 ///
2680 /// `Err` carries the `NoLineInfo` outcome for an artifact that cannot
2681 /// say which line execution is on — returned rather than silently
2682 /// degrading to instruction stepping, which would turn a missing line
2683 /// index into "why does step take four presses" instead of "this build
2684 /// has no line info".
2685 #[cfg(feature = "debug-hooks")]
2686 fn line_before(
2687 program: &crate::Program,
2688 flow: &Flow,
2689 granularity: StepGranularity,
2690 depth_before: usize,
2691 ) -> Result<Option<(u32, u32)>, crate::DebugRunOutcome> {
2692 match granularity {
2693 StepGranularity::Instruction => Ok(None),
2694 StepGranularity::Line => {
2695 Self::line_key_of(flow, program)
2696 .map(Some)
2697 .ok_or_else(|| crate::DebugRunOutcome {
2698 reason: crate::debug_control::DebugStopReason::NoLineInfo,
2699 position: Self::position_of(flow),
2700 depth: depth_before,
2701 })
2702 }
2703 }
2704 }
2705
2706 #[cfg(feature = "debug-hooks")]
2707 fn line_key_of(flow: &Flow, program: &crate::Program) -> Option<(u32, u32)> {
2708 Self::position_of(flow).and_then(|pos| program.debug_line_key(pos))
2709 }
2710
2711 /// The current `(container_idx, offset)` of a flow, or `None` when
2712 /// the innermost frame's container stack is empty — same read
2713 /// `build_debug_snapshot`'s own `frame_position` closure performs.
2714 #[cfg(feature = "debug-hooks")]
2715 fn position_of(flow: &Flow) -> Option<crate::DebugPosition> {
2716 flow.current_thread()
2717 .call_stack
2718 .top_container()
2719 .map(|cp| crate::DebugPosition {
2720 container_idx: cp.container_idx,
2721 offset: cp.offset,
2722 })
2723 }
2724
2725 /// A flow's current thread's call-stack depth.
2726 #[cfg(any(feature = "debug-hooks", feature = "testing"))]
2727 fn depth_of(flow: &Flow) -> usize {
2728 flow.current_thread().call_stack.len()
2729 }
2730
2731 /// Index of the call-stack frame a `<-` thread entered on, when the
2732 /// flow's current thread is one — the frame the debug surfaces label
2733 /// `thread` rather than by its own `frame_type`.
2734 ///
2735 /// `<-` pushes no call frame of its own (issue #3561): a spawned
2736 /// thread re-points its copy of the parent's innermost frame at the
2737 /// thread target and runs there, with [`Thread::base_depth`] marking
2738 /// where the parent's frames end. So the frame at `base_depth - 1` is
2739 /// the thread's entry frame, and it is what `docs/debugger-spec.md`
2740 /// §4's ruled `Thread` row describes: a frame you cannot return from,
2741 /// standing at the threaded knot's own container. `None` on the root
2742 /// thread, which has no such frame.
2743 fn thread_base_frame(flow: &Flow) -> Option<usize> {
2744 flow.can_pop_thread()
2745 .then(|| flow.current_thread().base_depth.saturating_sub(1))
2746 }
2747}
2748
2749#[cfg(test)]
2750#[expect(clippy::panic)]
2751mod tests {
2752 use super::*;
2753 use crate::link;
2754
2755 fn load_i079_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2756 let data = brink_compiler::compile_path(std::path::Path::new(
2757 "../../tests/tier1/choices/I079-once-only-choices-can-link-back-to-self/story.ink",
2758 ))
2759 .unwrap()
2760 .data;
2761 link(&data).unwrap()
2762 }
2763
2764 /// Step a story until it yields choices, panicking if it ends first.
2765 fn step_until_choices(story: &mut Story) -> Vec<Choice> {
2766 loop {
2767 match story.continue_single().unwrap() {
2768 Step::Choices(choices) => return choices,
2769 Step::Line(_) => {}
2770 Step::Done => panic!("story hit Done before presenting choices"),
2771 Step::End => panic!("story ended before presenting choices"),
2772 Step::Suspended => panic!("story parked before presenting choices"),
2773 }
2774 }
2775 }
2776
2777 /// Step a story, accumulating text, until it stops (choices, done, or
2778 /// end) — returns the accumulated text for content assertions. Terminals
2779 /// carry no text themselves; any trailing content already arrived as
2780 /// its own preceding `Step::Line`.
2781 fn step_until_choices_or_end(story: &mut Story) -> String {
2782 let mut text = String::new();
2783 loop {
2784 match story.continue_single().unwrap() {
2785 Step::Choices(_) | Step::Done | Step::End | Step::Suspended => return text,
2786 Step::Line(line) => text.push_str(&line.text),
2787 }
2788 }
2789 }
2790
2791 /// After selecting a once-only choice, the visit count for its target
2792 /// container must be > 0. Without this, the once-only filter in
2793 /// `handle_begin_choice` can never fire.
2794 #[test]
2795 fn select_choice_increments_visit_count_for_target() {
2796 let (program, line_tables) = load_i079_program();
2797 let mut story = Story::new(Arc::new(program), line_tables);
2798 let choices = step_until_choices(&mut story);
2799
2800 assert!(!choices.is_empty(), "expected at least one choice");
2801
2802 // Record the target_id of the first pending choice BEFORE selecting.
2803 let target_id = story.default.flow.pending_choices[0].target_id;
2804 let visit_before = story
2805 .default_context
2806 .visit_counts
2807 .get(&target_id)
2808 .copied()
2809 .unwrap_or(0);
2810
2811 story.choose(0).unwrap();
2812
2813 // After selection, the visit count for this target must have increased.
2814 let visit_after = story
2815 .default_context
2816 .visit_counts
2817 .get(&target_id)
2818 .copied()
2819 .unwrap_or(0);
2820 assert!(
2821 visit_after > visit_before,
2822 "visit count for choice target should increment after selection: \
2823 before={visit_before}, after={visit_after}"
2824 );
2825 }
2826
2827 /// Build a linked `Story` directly from `.ink` source (no fixture file),
2828 /// for cases that need a specific choice shape not already in `tests/`.
2829 fn story_from_source(src: &str) -> Story {
2830 let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
2831 let mut bytes = Vec::new();
2832 brink_format::write_inkb(&out.data, &mut bytes);
2833 let data = brink_format::read_inkb(&bytes).expect("decode");
2834 let (prog, tables) = link(&data).expect("link");
2835 Story::new(Arc::new(prog), tables)
2836 }
2837
2838 /// FS-3w guard (`docs/flow-suspension-spec.md` §10.1): `Step::Suspended`
2839 /// ships on the `Step` surface now but is **runtime-unreachable until
2840 /// FS-3r** — the E052 lowering fence keeps `await` from producing
2841 /// bytecode, so no `park`/`spill`/`resume` path exists to construct it.
2842 /// This pins both halves: the variant's terminal contract (terminals
2843 /// carry no payload — §7), and that driving a representative story
2844 /// (including one that spins up a shared flow) never yields a
2845 /// `Suspended` step, and that `wake_check` reports no woken flows
2846 /// because none can park.
2847 #[test]
2848 fn step_suspended_is_terminal_and_never_constructed_in_runtime() {
2849 // The variant behaves like any other terminal: no payload, reports
2850 // terminal.
2851 let parked = Step::Suspended;
2852 assert_eq!(parked.text(), "");
2853 assert!(parked.tags().is_empty());
2854 assert!(parked.is_terminal(), "a park is a turn boundary");
2855
2856 // Drive a small story with a shared flow to a terminal; nothing the
2857 // runtime produces is ever `Suspended`.
2858 let src = "Hello -> knot\n== knot ==\nWorld\n-> DONE\n";
2859 let mut story = story_from_source(src);
2860 story
2861 .spawn_flow_shared("f", None)
2862 .expect("spawn shared flow");
2863 for _ in 0..64 {
2864 let step = story.continue_single().expect("continue");
2865 assert!(
2866 !matches!(step, Step::Suspended),
2867 "runtime must never construct Step::Suspended before FS-3r"
2868 );
2869 if step.is_terminal() {
2870 break;
2871 }
2872 }
2873 for _ in 0..64 {
2874 let step = story.continue_flow_single("f").expect("continue flow");
2875 assert!(
2876 !matches!(step, Step::Suspended),
2877 "a shared flow must never construct Step::Suspended before FS-3r"
2878 );
2879 if step.is_terminal() {
2880 break;
2881 }
2882 }
2883
2884 // No flow can park, so `wake_check` reports an empty woken set.
2885 assert!(
2886 story.wake_check().is_empty(),
2887 "wake_check returns no woken flows until parks exist (FS-3r)"
2888 );
2889 }
2890
2891 /// #999: a shared flow that emits text forever must error at
2892 /// `FlowInstance::LINE_LIMIT` rather than growing `continue_flow_maximally_shared`'s
2893 /// returned `Vec<Step>` without bound — the shared-flow analogue of
2894 /// `drive_to_terminal_errors_at_line_limit` above, exercised through the
2895 /// `Story`-level entry point the wasm leg (`brink-web`) actually calls.
2896 #[test]
2897 fn continue_flow_maximally_shared_errors_at_line_limit() {
2898 let src = "-> spam\n\n=== spam ===\nLine.\n-> spam\n";
2899 let mut story = story_from_source(src);
2900 story
2901 .spawn_flow_shared("f", None)
2902 .expect("spawn shared flow at the root (immediately diverts into `spam`)");
2903 let err = story
2904 .continue_flow_maximally_shared("f")
2905 .expect_err("infinite-emitting flow should hit the line limit rather than hang");
2906 match err {
2907 RuntimeError::LineLimitExceeded(n) => {
2908 assert_eq!(n, FlowInstance::LINE_LIMIT);
2909 }
2910 other => panic!("expected LineLimitExceeded, got {other:?}"),
2911 }
2912 }
2913
2914 /// `Choice.index` (the live, visible choice list) numbers the visible
2915 /// choices contiguously — C#'s `currentChoices[i].index` — and
2916 /// `choose` maps that number to the `pending_choices` position: an
2917 /// invisible-default fallback choice (`* ->`) mixed in with visible
2918 /// choices occupies a `pending_choices` slot but never a visible index
2919 /// (issue #3527; this reverses the earlier raw-position contract, which
2920 /// made the visible indices skip a value the reference never skips).
2921 #[test]
2922 fn choice_index_is_the_visible_position_with_invisible_default_mixed_in() {
2923 let src = "-(start)\n\
2924 * [First] -> a\n\
2925 * -> b\n\
2926 * [Third] -> c\n\
2927 -(a) Went A.\n-> DONE\n\
2928 -(b) Went B.\n-> DONE\n\
2929 -(c) Went C.\n-> DONE\n";
2930 let mut story = story_from_source(src);
2931 let choices = step_until_choices(&mut story);
2932
2933 // The invisible-default fallback (pending position 1) is filtered
2934 // out of the visible list and takes no index: 0, then 1.
2935 assert_eq!(
2936 choices.iter().map(|c| c.index).collect::<Vec<_>>(),
2937 vec![0, 1],
2938 "visible choice indices must be contiguous: {choices:?}"
2939 );
2940 assert_eq!(story.default.flow.pending_choices.len(), 3);
2941
2942 // Choosing the second visible entry by its index must select the
2943 // "Third" branch, not the invisible-default fallback.
2944 story
2945 .choose(choices[1].index)
2946 .expect("choose by visible index");
2947 let text = step_until_choices_or_end(&mut story);
2948 assert!(text.contains("Went C"), "expected the Third branch: {text}");
2949 }
2950
2951 /// `DebugSnapshot.pending_choices[].index` must agree with the live
2952 /// `Choice.index` — both derive from the same visible-choice pass over
2953 /// `pending_choices` (`resolved_choices_for`). A studio consumer restoring
2954 /// a Choice[] from a `DebugSnapshot` (rather than a live `Choice` list)
2955 /// depends on this to dispatch `choose()` correctly.
2956 #[test]
2957 fn debug_snapshot_choice_index_matches_live_choice_index() {
2958 let src = "-(start)\n\
2959 * [First] -> a\n\
2960 * -> b\n\
2961 * [Third] -> c\n\
2962 -(a) Went A.\n-> DONE\n\
2963 -(b) Went B.\n-> DONE\n\
2964 -(c) Went C.\n-> DONE\n";
2965 let mut story = story_from_source(src);
2966 let live_choices = step_until_choices(&mut story);
2967 let snap = story.debug_snapshot();
2968
2969 assert_eq!(snap.pending_choices.len(), live_choices.len());
2970 for (live, dbg) in live_choices.iter().zip(snap.pending_choices.iter()) {
2971 assert_eq!(
2972 dbg.index, live.index,
2973 "DebugChoice.index must match the live Choice.index"
2974 );
2975 }
2976 }
2977
2978 /// On the second pass through a choice set with once-only choices,
2979 /// a choice whose target has already been visited must NOT appear
2980 /// in `pending_choices`.
2981 #[test]
2982 fn once_only_choice_excluded_on_second_pass() {
2983 let (program, line_tables) = load_i079_program();
2984 let mut story = Story::new(Arc::new(program), line_tables);
2985
2986 let first_choices = step_until_choices(&mut story);
2987 assert!(
2988 first_choices
2989 .iter()
2990 .any(|c| c.text.contains("First choice")),
2991 "first pass should contain 'First choice', got: {first_choices:?}"
2992 );
2993
2994 story.choose(0).unwrap();
2995
2996 let second_choices = step_until_choices(&mut story);
2997 assert!(
2998 !second_choices
2999 .iter()
3000 .any(|c| c.text.contains("First choice")),
3001 "second pass should NOT contain 'First choice' (once-only, already visited), \
3002 got: {second_choices:?}"
3003 );
3004 }
3005
3006 // ── Choice thread forking ──────────────────────────────────────────
3007
3008 fn load_i083_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3009 let data = brink_compiler::compile_path(std::path::Path::new(
3010 "../../tests/tier1/choices/I083-choice-thread-forking/story.ink",
3011 ))
3012 .unwrap()
3013 .data;
3014 link(&data).unwrap()
3015 }
3016
3017 /// When a choice is created inside a tunnel, the call stack at that
3018 /// moment (including the tunnel frame with its temps) must be captured.
3019 /// After the tunnel returns and the choice is presented, the snapshot
3020 /// should still reflect the tunnel-era call stack depth (>= 2 frames).
3021 #[test]
3022 fn pending_choice_captures_tunnel_call_stack() {
3023 let (program, line_tables) = load_i083_program();
3024 let mut story = Story::new(Arc::new(program), line_tables);
3025 let _choices = step_until_choices(&mut story);
3026
3027 // At this point the tunnel has returned, so the live call_stack
3028 // has only the root frame.
3029 let current_thread = story.default.flow.current_thread();
3030 assert_eq!(
3031 current_thread.call_stack.len(),
3032 1,
3033 "live call stack should be 1 frame (root) after tunnel return"
3034 );
3035
3036 // But the pending choice's fork should have captured the
3037 // call stack from inside the tunnel (root + tunnel = 2 frames).
3038 assert!(!story.default.flow.pending_choices.is_empty());
3039 let fork = &story.default.flow.pending_choices[0].thread_fork;
3040 assert!(
3041 fork.call_stack.len() >= 2,
3042 "choice fork should have >= 2 frames (root + tunnel), got {}",
3043 fork.call_stack.len()
3044 );
3045 }
3046
3047 /// After selecting a choice that was created inside a tunnel,
3048 /// `select_choice` must restore the tunnel's call frame so that
3049 /// temp variables from the tunnel scope are accessible.
3050 #[test]
3051 fn select_choice_restores_tunnel_frame_with_temps() {
3052 let (program, line_tables) = load_i083_program();
3053 let mut story = Story::new(Arc::new(program), line_tables);
3054 let _choices = step_until_choices(&mut story);
3055
3056 // Before choosing: only root frame, no tunnel temps.
3057 assert_eq!(story.default.flow.current_thread().call_stack.len(), 1);
3058
3059 story.choose(0).unwrap();
3060
3061 // After choosing: the tunnel frame should be restored.
3062 // The call stack should have at least 2 frames (root + tunnel).
3063 let call_stack = &story.default.flow.current_thread().call_stack;
3064 assert!(
3065 call_stack.len() >= 2,
3066 "call stack should be restored to tunnel depth after choice selection, \
3067 got {} frame(s)",
3068 call_stack.len()
3069 );
3070
3071 // The tunnel frame (last frame) should have temp x = Int(1).
3072 let tunnel_temps = call_stack.temps(call_stack.len() - 1);
3073 assert!(
3074 !tunnel_temps.is_empty(),
3075 "tunnel frame should have temp variables"
3076 );
3077 assert_eq!(
3078 tunnel_temps[0],
3079 Value::Int(1),
3080 "tunnel frame temps[0] should be Int(1) (the parameter x)"
3081 );
3082 }
3083
3084 /// PR #3369 review: `debug_set_temp` (the W16/#3309 live-value-editing
3085 /// seam) must commit through `CallFrame::write_temp` — the single path
3086 /// every real temp-slot store in the VM funnels through, per that
3087 /// field's own doc comment — so the slot's `temps_written` bit is set
3088 /// exactly like a real `DeclareTemp`/`SetTemp` would.
3089 ///
3090 /// Forces the frame's slot 0 back to `write_temp`'s own "exists but
3091 /// never written" shape directly (`Value::Null` with the bit unset) —
3092 /// exactly the state a not-yet-declared sibling temp is left in when a
3093 /// *different*, higher-slotted temp is written first (`write_temp`
3094 /// zero-pads every lower index). Before the fix, `debug_set_temp` wrote
3095 /// `*target = value` straight through `temps.get_mut`, leaving
3096 /// `temps_written` stale — so `Opcode::GetTemp`'s issue #3354
3097 /// uninitialized-slot gate would still treat the edited slot as never
3098 /// written on the next read, discarding the edit and substituting the
3099 /// missing-variable default plus a spurious `RuntimeWarning`.
3100 #[test]
3101 fn debug_set_temp_marks_the_slot_written() {
3102 let (program, tables) =
3103 compile_source_for_flow("-> k\n=== k ===\n~ temp n = 1\nSaw {n}.\n-> END\n");
3104 let mut story = Story::<FastRng>::new(Arc::new(program), tables);
3105 // Run past the `DeclareTemp` so the frame's `temps`/`temps_written`
3106 // for `n` actually exist.
3107 match story.continue_single().expect("VM step") {
3108 Step::Line(_) => {}
3109 other => panic!("expected a line, got {other:?}"),
3110 }
3111
3112 {
3113 let call_stack = &mut story.default.flow.current_thread_mut().call_stack;
3114 let top = call_stack.top_depth().expect("root frame");
3115 assert!(
3116 !call_stack.temps(top).is_empty(),
3117 "DeclareTemp must have run by now: {call_stack:?}"
3118 );
3119 call_stack.write_temp(top, 0, Value::Null);
3120 call_stack.clear_temp_written(top, 0);
3121 }
3122
3123 assert!(
3124 story.debug_set_temp(0, 0, Value::Int(42)),
3125 "the slot already exists, so the edit must be accepted"
3126 );
3127
3128 let call_stack = &story.default.flow.current_thread().call_stack;
3129 let top = call_stack.top_depth().expect("root frame");
3130 assert_eq!(
3131 call_stack.temp(top, 0),
3132 Some(&Value::Int(42)),
3133 "the edited value must land in the slot"
3134 );
3135 assert!(
3136 call_stack.is_temp_written(top, 0),
3137 "debug_set_temp must mark the slot written via CallStack::write_temp"
3138 );
3139 }
3140
3141 // ── Tags ──────────────────────────────────────────────────────────
3142
3143 fn load_tags_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3144 let data = brink_compiler::compile_path(std::path::Path::new(
3145 "../../tests/tier3/tags/tags/story.ink",
3146 ))
3147 .unwrap()
3148 .data;
3149 link(&data).unwrap()
3150 }
3151
3152 fn load_tags_in_choice_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3153 let data = brink_compiler::compile_path(std::path::Path::new(
3154 "../../tests/tier3/tags/tagsInChoice/story.ink",
3155 ))
3156 .unwrap()
3157 .data;
3158 link(&data).unwrap()
3159 }
3160
3161 #[test]
3162 fn line_exposes_tags() {
3163 let (program, line_tables) = load_tags_program();
3164 let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
3165 let lines = story.continue_maximally().unwrap();
3166 // The first line should have both tags.
3167 let first = lines.first().expect("expected at least one line");
3168 assert!(
3169 !matches!(first, Step::Choices(_)),
3170 "expected Text or End, got Choices"
3171 );
3172 assert_eq!(first.tags(), &["author: Joe", "title: My Great Story"],);
3173 }
3174
3175 #[test]
3176 fn choice_exposes_tags() {
3177 let (program, line_tables) = load_tags_in_choice_program();
3178 let mut story = Story::new(Arc::new(program), line_tables);
3179 let choices = step_until_choices(&mut story);
3180 assert!(!choices.is_empty());
3181 // The choice in tagsInChoice has tags "one" and "two"
3182 assert!(
3183 !choices[0].tags.is_empty(),
3184 "choice should have tags, got: {choices:?}"
3185 );
3186 }
3187
3188 // ── Thread support ──────────────────────────────────────────────────
3189
3190 fn load_i091_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3191 let data = brink_compiler::compile_path(std::path::Path::new(
3192 "../../tests/tier1/choices/I091-choice-count/story.ink",
3193 ))
3194 .unwrap()
3195 .data;
3196 link(&data).unwrap()
3197 }
3198
3199 /// `<- choices` (thread) must create choices AND return to the main
3200 /// flow so that `CHOICE_COUNT()` can evaluate. The thread body
3201 /// should be called like a tunnel — when its container stack empties,
3202 /// execution returns to the caller. Non-root frames must always pop
3203 /// back to their caller, even when pending choices exist.
3204 #[test]
3205 fn thread_call_returns_to_main_flow() {
3206 let (program, line_tables) = load_i091_program();
3207 let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
3208
3209 let lines = story.continue_maximally().unwrap();
3210 // I091 should output "2\n" (CHOICE_COUNT) then present 2 choices.
3211 let full_text: String = lines.iter().map(Step::text).collect();
3212 assert!(
3213 full_text.starts_with('2'),
3214 "output should start with '2' from CHOICE_COUNT(), got: {full_text:?}"
3215 );
3216 let last = lines.last().expect("expected at least one line");
3217 match last {
3218 Step::Choices(choices) => {
3219 assert_eq!(choices.len(), 2, "expected 2 choices");
3220 }
3221 other => panic!("expected Choices, got {other:?}"),
3222 }
3223 }
3224
3225 // ── FlowInstance::drive_to_terminal (F6.1a shared drive-to-terminal op) ──
3226
3227 /// Compile `.ink` source directly into a linked `(Program, line_tables)`
3228 /// pair, bypassing `Story` so tests can drive a bare `FlowInstance`
3229 /// directly — the way a `Story`-free consumer (e.g. an engine
3230 /// integration) would.
3231 fn compile_source_for_flow(src: &str) -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3232 let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
3233 let mut bytes = Vec::new();
3234 brink_format::write_inkb(&out.data, &mut bytes);
3235 let data = brink_format::read_inkb(&bytes).expect("decode");
3236 link(&data).expect("link")
3237 }
3238
3239 #[test]
3240 fn drive_to_terminal_stops_at_done() {
3241 let (program, tables) = compile_source_for_flow("Hello.\n-> DONE\n");
3242 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3243 let mut local = FlowLocal::new();
3244 let mut view = ContextView::new(&mut world, &mut local);
3245 let lines = flow
3246 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3247 .expect("drive succeeds");
3248 let (last, rest) = lines.split_last().expect("at least one line");
3249 assert!(matches!(last, Step::Done), "expected Done, got {last:?}");
3250 assert!(
3251 rest.iter().all(|l| matches!(l, Step::Line(_))),
3252 "every line before the terminal one should be Text, got {rest:?}"
3253 );
3254 }
3255
3256 #[test]
3257 fn drive_to_terminal_stops_at_choices() {
3258 let (program, tables) =
3259 compile_source_for_flow("Hello.\n* Pick me\n Picked.\n -> DONE\n");
3260 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3261 let mut local = FlowLocal::new();
3262 let mut view = ContextView::new(&mut world, &mut local);
3263 let lines = flow
3264 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3265 .expect("drive succeeds");
3266 let (last, rest) = lines.split_last().expect("at least one line");
3267 assert!(
3268 matches!(last, Step::Choices(_)),
3269 "expected Choices, got {last:?}"
3270 );
3271 assert!(
3272 rest.iter().all(|l| matches!(l, Step::Line(_))),
3273 "every line before the terminal one should be Text, got {rest:?}"
3274 );
3275 }
3276
3277 #[test]
3278 fn drive_to_terminal_stops_at_end() {
3279 let (program, tables) = compile_source_for_flow("Hello.\n-> END\n");
3280 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3281 let mut local = FlowLocal::new();
3282 let mut view = ContextView::new(&mut world, &mut local);
3283 let lines = flow
3284 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3285 .expect("drive succeeds");
3286 let (last, rest) = lines.split_last().expect("at least one line");
3287 assert!(matches!(last, Step::End), "expected End, got {last:?}");
3288 assert!(
3289 rest.iter().all(|l| matches!(l, Step::Line(_))),
3290 "every line before the terminal one should be Text, got {rest:?}"
3291 );
3292 }
3293
3294 /// A knot that prints and re-diverts into itself forever never reaches a
3295 /// terminal line, so `drive_to_terminal` must give up at
3296 /// `FlowInstance::LINE_LIMIT` rather than looping forever — proving the
3297 /// extracted op kept `Story::continue_maximally_impl`'s safety cap.
3298 #[test]
3299 fn drive_to_terminal_errors_at_line_limit() {
3300 let (program, tables) =
3301 compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
3302 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3303 let mut local = FlowLocal::new();
3304 let mut view = ContextView::new(&mut world, &mut local);
3305 let err = flow
3306 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3307 .expect_err("infinite content should hit the line limit rather than hang");
3308 match err {
3309 RuntimeError::LineLimitExceeded(n) => {
3310 assert_eq!(n, FlowInstance::LINE_LIMIT);
3311 }
3312 other => panic!("expected LineLimitExceeded, got {other:?}"),
3313 }
3314 }
3315
3316 // ── FlowInstance::drive (F6.2 pausable Layer-2 drive op) ─────────────
3317
3318 /// Defers (`Pending`) its first call, then resolves — mirrors the
3319 /// `DeferOnce` pattern used for the flow-level resume gap elsewhere in
3320 /// the runtime's test suite (`tests/session.rs`, `tests/speculation.rs`).
3321 struct DeferOnce {
3322 deferred: std::cell::Cell<bool>,
3323 }
3324
3325 impl ExternalFnHandler for DeferOnce {
3326 fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
3327 if name == "pause_once" && !self.deferred.get() {
3328 self.deferred.set(true);
3329 ExternalResult::Pending
3330 } else {
3331 ExternalResult::Resolved(Value::Int(2))
3332 }
3333 }
3334 }
3335
3336 /// `drive` pauses cleanly (no error) on a deferred external, and resuming
3337 /// after [`FlowInstance::resolve_external`] continues the *same* logical
3338 /// drive to its terminal line — the pausable sibling of
3339 /// `drive_to_terminal`, which instead errors on a deferred external.
3340 #[test]
3341 fn drive_pauses_on_awaiting_external_then_resumes() {
3342 let (program, tables) = compile_source_for_flow(
3343 "EXTERNAL pause_once(x)\nHello.\nWorld.\nValue: {pause_once(1)}.\n-> DONE\n",
3344 );
3345 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3346 let mut local = FlowLocal::new();
3347 let mut view = ContextView::new(&mut world, &mut local);
3348 let handler = DeferOnce {
3349 deferred: std::cell::Cell::new(false),
3350 };
3351 let mut budget = 10usize;
3352
3353 let outcome = flow
3354 .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
3355 .expect("first drive call succeeds");
3356 let paused_lines = match outcome {
3357 DriveOutcome::AwaitingExternal(lines) => lines,
3358 other @ DriveOutcome::Terminal(_) => panic!("expected AwaitingExternal, got {other:?}"),
3359 };
3360 let paused_text: String = paused_lines.iter().map(Step::text).collect();
3361 assert!(
3362 paused_text.contains("Hello"),
3363 "text produced before the pause should include 'Hello.'; got {paused_text:?}"
3364 );
3365 assert!(
3366 !paused_text.contains("Value"),
3367 "the line calling the deferred external should not have completed yet; got {paused_text:?}"
3368 );
3369 assert_eq!(
3370 budget,
3371 10 - paused_lines.len(),
3372 "budget should decrement by exactly the lines produced before the pause"
3373 );
3374
3375 flow.resolve_external(Value::Int(2));
3376 let outcome = flow
3377 .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
3378 .expect("second drive call resumes and completes");
3379 let resumed_lines = match outcome {
3380 DriveOutcome::Terminal(lines) => lines,
3381 other @ DriveOutcome::AwaitingExternal(_) => panic!("expected Terminal, got {other:?}"),
3382 };
3383 let resumed_text: String = resumed_lines.iter().map(Step::text).collect();
3384 assert!(
3385 resumed_text.contains("Value: 2"),
3386 "the resolved external's value should be inlined; got {resumed_text:?}"
3387 );
3388 assert!(
3389 matches!(resumed_lines.last(), Some(Step::Done)),
3390 "expected the drive to finish at Done, got {resumed_lines:?}"
3391 );
3392 assert_eq!(
3393 budget,
3394 10 - paused_lines.len() - resumed_lines.len(),
3395 "budget must keep decrementing across the resume — not reset to a fresh cap \
3396 (the whole point of the caller-owned budget: one bound per logical drive, not \
3397 per resume)"
3398 );
3399 }
3400
3401 /// A knot that prints and re-diverts into itself forever never reaches a
3402 /// terminal line. `drive` must give up when the caller's `budget` is
3403 /// exhausted — which can be far smaller than
3404 /// [`FlowInstance::LINE_LIMIT`] — rather than looping until the much
3405 /// larger production default, proving the budget is a real per-call
3406 /// parameter and not just a relabeling of the constant.
3407 #[test]
3408 fn drive_errors_when_caller_budget_exhausted() {
3409 let (program, tables) =
3410 compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
3411 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3412 let mut local = FlowLocal::new();
3413 let mut view = ContextView::new(&mut world, &mut local);
3414 let mut budget = 3usize;
3415 let err = flow
3416 .drive::<FastRng>(
3417 &program,
3418 &tables,
3419 &mut view,
3420 &FallbackHandler,
3421 None,
3422 &mut budget,
3423 )
3424 .expect_err("infinite content should hit the caller's small budget");
3425 match err {
3426 RuntimeError::LineLimitExceeded(n) => assert_eq!(
3427 n, 3,
3428 "reported limit should be the caller's budget, not the unrelated LINE_LIMIT constant"
3429 ),
3430 other => panic!("expected LineLimitExceeded, got {other:?}"),
3431 }
3432 assert_eq!(budget, 0, "budget should be fully consumed, not partially");
3433 }
3434
3435 // ── free `save_state`/`load_state` (F6.1b) ───────────────────────────
3436
3437 /// A `Story`-free save/load roundtrip: drive a bare `FlowInstance` +
3438 /// `ContextView` (no `Story` anywhere), capture state via the lifted
3439 /// `save_state` free function, mutate the live context, then restore via
3440 /// `load_state` and confirm the mutation is undone. Proves the lifted
3441 /// functions work for a consumer (e.g. `bevy-brink`) that never
3442 /// constructs a `Story`.
3443 #[test]
3444 fn free_fn_save_load_roundtrip_without_story() {
3445 let (program, tables) = compile_source_for_flow(
3446 "VAR gold = 0\n\
3447 -> shrine\n\
3448 === shrine ===\n\
3449 ~ gold = 5\n\
3450 Shrine text.\n\
3451 -> DONE\n\
3452 === reader ===\n\
3453 {READ_COUNT(-> shrine)}\n\
3454 -> DONE\n",
3455 // `reader` is never entered — it exists only so the compiler's
3456 // counting-flags pass sees a visit-count read of `shrine` and
3457 // sets `CountingFlags::VISITS` on it (a knot with no read of its
3458 // own visit count anywhere in the program has counting disabled
3459 // entirely, an existing compiler optimization).
3460 );
3461 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3462 let mut local = FlowLocal::new();
3463 {
3464 let mut view = ContextView::new(&mut world, &mut local);
3465 flow.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3466 .expect("drive succeeds");
3467 }
3468
3469 let gold_slot = program.global_index("gold").expect("gold declared");
3470 let shrine_id = program.find_path_target("shrine").expect("shrine exists");
3471
3472 let save = {
3473 let view = ContextView::new(&mut world, &mut local);
3474 crate::save_state(&program, &view)
3475 };
3476 assert_eq!(save.globals.get("gold"), Some(&Value::Int(5)));
3477 assert_eq!(
3478 save.visits
3479 .iter()
3480 .find(|e| e.id == shrine_id)
3481 .map(|e| e.count),
3482 Some(1),
3483 "shrine should have a captured visit entry"
3484 );
3485
3486 // Mutate the live context directly through the trait.
3487 {
3488 let mut view = ContextView::new(&mut world, &mut local);
3489 view.set_global(gold_slot, Value::Int(999));
3490 view.set_visit_count(shrine_id, 42);
3491 }
3492 {
3493 let view = ContextView::new(&mut world, &mut local);
3494 assert_eq!(view.global(gold_slot), &Value::Int(999));
3495 assert_eq!(view.visit_count(shrine_id), 42);
3496 }
3497
3498 // Restore via the lifted `load_state` and confirm the mutation is
3499 // undone.
3500 let report = {
3501 let mut view = ContextView::new(&mut world, &mut local);
3502 crate::load_state(&program, &mut view, &save)
3503 };
3504 assert!(report.unknown_globals.is_empty(), "clean load: {report:?}");
3505
3506 let view = ContextView::new(&mut world, &mut local);
3507 assert_eq!(view.global(gold_slot), &Value::Int(5));
3508 assert_eq!(view.visit_count(shrine_id), 1);
3509 }
3510}