Skip to main content

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, &param_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}