brink_runtime/story/flow_instance.rs
1//! [`FlowInstance`] — a single independent execution context within a
2//! story, and the low-level orchestration entry points documented in
3//! `CLAUDE.md`'s "Runtime public API" (`advance`/`begin_function_eval`/etc.).
4
5use alloc::borrow::ToOwned;
6use alloc::collections::BTreeMap;
7use alloc::format;
8use alloc::string::{String, ToString};
9use alloc::vec;
10use alloc::vec::Vec;
11
12use brink_format::{DefinitionId, PluralResolver, Value};
13
14use crate::error::{RanOutOfContentCause, RuntimeError};
15use crate::output::OutputBuffer;
16use crate::program::Program;
17use crate::rng::StoryRng;
18use crate::state::ContextAccess;
19use crate::vm;
20use crate::world::{ResolvedPolicy, World};
21
22use super::call_stack::{
23 CallFrame, CallFrameType, CallStack, ChoiceDisplay, ContainerPosition, ExecMode, Flow,
24 PendingTerminal, Thread,
25};
26use super::external::{ExternalFnHandler, ExternalResult, FunctionEval};
27use super::types::{BlockId, Choice, Element, OutputLine, Stats, Step, StepOutcome, StoryStatus};
28
29// ── FlowInstance ────────────────────────────────────────────────────────────
30
31/// A single independent execution context within a story. The default flow
32/// runs from the root container; named flows can be spawned at arbitrary
33/// entry points via [`FlowInstance::new_at`].
34///
35/// A `FlowInstance` is opaque from outside the crate: its internal fields
36/// (`flow`, `status`, `stats`) are crate-private, but consumers can hold,
37/// clone, serialize, and pass `&mut FlowInstance` to the runtime's step
38/// functions. Use the inherent methods ([`step_single_line`](Self::step_single_line),
39/// [`choose`](Self::choose), [`transcript`](Self::transcript),
40/// [`status`](Self::status), etc.) for all interaction.
41#[derive(Clone, Debug)]
42pub struct FlowInstance {
43 pub(crate) flow: Flow,
44 pub(crate) status: StoryStatus,
45 pub(crate) stats: Stats,
46 /// Transient state for an in-progress engine→ink function evaluation
47 /// ([`begin_function_eval`](Self::begin_function_eval)). `Some` only
48 /// while a from-game call is mid-flight (possibly paused on an
49 /// external); `None` during normal play. Not meaningful to persist.
50 pub(crate) eval: Option<EvalState>,
51 /// Whether host **semantic** access to `#@private` definitions is
52 /// refused on this flow instance (M-2b, `docs/modules-spec.md` §4
53 /// boundary rule 2/3). `true` by default. Mirrors
54 /// [`Story`]'s own flag for consumers — `bevy-brink`'s per-entity
55 /// orchestration, [`crate::Speculation`] — that drive a `FlowInstance`
56 /// directly, bypassing `Story` entirely. [`Story`] keeps every
57 /// `FlowInstance` it owns (`default`, named, shared) synced to its own
58 /// flag via [`Story::set_visibility_enforcement`](crate::Story::set_visibility_enforcement),
59 /// so the two never diverge for story-owned flows.
60 pub(crate) enforce_visibility: bool,
61}
62
63/// Bookkeeping for an in-progress engine→ink function evaluation.
64#[derive(Debug, Clone)]
65pub(crate) struct EvalState {
66 /// Value-stack length recorded before arguments were pushed, so the
67 /// return value (and any leftover args) can be reclaimed on return.
68 pub value_floor: usize,
69 /// Pending-choice count when the eval began. A function that *grows*
70 /// this presented a choice — illegal, and distinct from choices the
71 /// main story may already have waiting.
72 pub choice_floor: usize,
73}
74
75/// Outcome of a single [`FlowInstance::drive`] call: either the drive
76/// reached a terminal step, or it paused on a deferred external mid-drive.
77/// Both variants carry every [`Step`] produced during *this* call — for
78/// `AwaitingExternal`, that's the (possibly empty) run of `Step::Line`
79/// produced before the pause; for `Terminal`, the terminal step is always
80/// the last element (see [`FlowInstance::drive`]).
81#[derive(Debug, Clone)]
82pub enum DriveOutcome {
83 /// Reached a terminal step ([`Step::Done`], [`Step::Choices`], or
84 /// [`Step::End`]) — always the last element of the `Vec`.
85 Terminal(Vec<Step>),
86 /// Paused on a deferred external
87 /// ([`ExternalResult::Pending`](crate::ExternalResult::Pending)).
88 /// Resolve it ([`FlowInstance::resolve_external`]) and call
89 /// [`FlowInstance::drive`] again — with the **same** `budget` — to
90 /// resume.
91 AwaitingExternal(Vec<Step>),
92}
93
94impl FlowInstance {
95 /// Create a new flow instance starting at the program's root container,
96 /// along with a fresh [`World`] initialized from the program's global
97 /// defaults.
98 pub fn new_at_root(program: &Program) -> (Self, World) {
99 Self::new_at(program, program.root_idx())
100 }
101
102 /// Create a new flow instance starting at an arbitrary container index,
103 /// along with a fresh [`World`]. Use this to spawn a named flow at a
104 /// specific entry point. The caller is responsible for deciding whether
105 /// to share the returned `World` with other flows or discard it and
106 /// reuse an existing one.
107 pub fn new_at(program: &Program, container_idx: u32) -> (Self, World) {
108 let globals = program.global_defaults();
109 let initial_frame = CallFrame {
110 return_address: None,
111 temps: Vec::new(),
112 container_stack: vec![ContainerPosition {
113 container_idx,
114 offset: 0,
115 }],
116 frame_type: CallFrameType::Root,
117 external_fn_id: None,
118 function_output_start: None,
119 };
120 let initial_thread = Thread {
121 call_stack: CallStack::new(initial_frame),
122 };
123 let flow_instance = Self {
124 flow: Flow {
125 threads: vec![initial_thread],
126 value_stack: Vec::new(),
127 output: OutputBuffer::new(),
128 pending_choices: Vec::new(),
129 current_tags: Vec::new(),
130 in_tag: false,
131 skipping_choice: false,
132 did_safe_exit: false,
133 did_unsafe_yield: false,
134 ran_out_of_content_cause: RanOutOfContentCause::default(),
135 exec_mode: ExecMode::default(),
136 pure_callback: crate::story::PureCallbackState::default(),
137 next_block_id: 0,
138 pending_terminal: PendingTerminal::default(),
139 },
140 status: StoryStatus::Active,
141 stats: Stats::default(),
142 eval: None,
143 enforce_visibility: true,
144 };
145 // All existing construction paths default to the all-`World`
146 // policy (see `docs/scoped-flow-state-spec.md` "The policy") — this
147 // is the fast path that needs no `Program` symbol lookups and
148 // can't fail, so `new_at`/`new_at_root` keep their infallible
149 // `(Self, World)` signature.
150 let world = World::from_globals(globals, ResolvedPolicy::all_world());
151 (flow_instance, world)
152 }
153
154 /// Enable or disable host visibility enforcement on this flow instance
155 /// (M-2b, `docs/modules-spec.md` §4 boundary rule 3). Enforcement is
156 /// **on** by default: [`choose_path_string`](Self::choose_path_string)/
157 /// [`choose_path_string_with_args`](Self::choose_path_string_with_args)
158 /// into a `#@private` knot/stitch, and
159 /// [`begin_function_eval`](Self::begin_function_eval)/
160 /// [`begin_function_value_eval`](Self::begin_function_value_eval) of a
161 /// `#@private` function, return [`RuntimeError::PrivateAccess`].
162 ///
163 /// This mirrors [`Story::set_visibility_enforcement`](crate::Story::set_visibility_enforcement)
164 /// for consumers that drive a `FlowInstance` directly — `bevy-brink`'s
165 /// per-entity orchestration and [`crate::Speculation`] — rather than
166 /// through a [`Story`](crate::Story). A `Story` keeps every
167 /// `FlowInstance` it owns synced to its own flag when this is called on
168 /// the `Story`, so callers that only ever go through `Story` never need
169 /// to call this directly.
170 pub fn set_visibility_enforcement(&mut self, enforce: bool) {
171 self.enforce_visibility = enforce;
172 }
173
174 /// Whether host visibility enforcement is currently on for this flow
175 /// instance (default `true`).
176 #[must_use]
177 pub fn visibility_enforced(&self) -> bool {
178 self.enforce_visibility
179 }
180
181 /// Set the dev/prod execution mode on this flow instance (NS-A4,
182 /// [`ExecMode`] — see its docs for the §4b doctrine). Mirrors
183 /// [`Story::set_exec_mode`](crate::Story::set_exec_mode) for consumers
184 /// that drive a `FlowInstance` directly (`bevy-brink`,
185 /// [`crate::Speculation`]). Takes effect immediately — the mode is
186 /// consulted at each ordering-verb execution.
187 pub fn set_exec_mode(&mut self, mode: ExecMode) {
188 self.flow.exec_mode = mode;
189 }
190
191 /// The current dev/prod execution mode (NS-A4, [`ExecMode`]).
192 #[must_use]
193 pub fn exec_mode(&self) -> ExecMode {
194 self.flow.exec_mode
195 }
196
197 /// Maximum VM steps per `continue_maximally` call before erroring.
198 /// Prevents infinite loops from malformed bytecode.
199 const STEP_LIMIT: u64 = 1_000_000;
200
201 /// Execute until one complete line of output is available, or until a
202 /// yield point (choices/done/ended) if no newline occurs first.
203 ///
204 /// Returns a [`Step`] telling the caller what happened (`Line`/`Done`/
205 /// `Choices`/`End`). This is the simple API for consumers whose
206 /// external handler never defers: if the handler returns
207 /// [`ExternalResult::Pending`], this errors with
208 /// [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall).
209 /// For pausable world-access bindings, use [`advance`](Self::advance).
210 pub fn step_single_line<R: StoryRng>(
211 &mut self,
212 program: &Program,
213 line_tables: &[Vec<brink_format::LineEntry>],
214 context: &mut (impl ContextAccess + ?Sized),
215 handler: &dyn ExternalFnHandler,
216 resolver: Option<&dyn PluralResolver>,
217 ) -> Result<Step, RuntimeError> {
218 match self.advance::<R>(program, line_tables, context, handler, resolver)? {
219 StepOutcome::Step(step) => Ok(step),
220 StepOutcome::AwaitingExternal => {
221 // Preserve historical behavior for consumers using this
222 // (non-pausing) API: a deferred external they can't resolve
223 // is an error.
224 let id = self
225 .flow
226 .external_fn_id()
227 .ok_or(RuntimeError::CallStackUnderflow)?;
228 Err(RuntimeError::UnresolvedExternalCall(id))
229 }
230 }
231 }
232
233 /// Maximum lines produced by a single [`drive_to_terminal`](Self::drive_to_terminal)
234 /// call before erroring. Safety net against infinite loops from
235 /// malformed bytecode.
236 pub const LINE_LIMIT: usize = 10_000;
237
238 /// Step this flow forward until the next terminal step (`Done`,
239 /// `Choices`, or `End`), collecting every [`Step`] produced along the
240 /// way.
241 ///
242 /// This is the single Layer-2 "drive to terminal" loop: [`Story`]'s
243 /// `continue_maximally*` family is a thin wrapper over it, and any other
244 /// holder of a `FlowInstance` (e.g. an engine integration like
245 /// `bevy-brink`) should reach for this instead of hand-rolling the same
246 /// loop. Semantics:
247 ///
248 /// - Steps via [`step_single_line`](Self::step_single_line): a deferred
249 /// external ([`ExternalResult::Pending`]) is **not** paused on here —
250 /// it errors with [`RuntimeError::UnresolvedExternalCall`], exactly as
251 /// `step_single_line` does. Callers that need to pause on world-access
252 /// externals mid-drive should drive [`advance`](Self::advance)
253 /// themselves rather than use this method.
254 /// - Stops at the first [`Step`] for which [`Step::is_terminal`] returns
255 /// `true`; that step is always the last element of the returned
256 /// `Vec`, and every element before it is a [`Step::Line`].
257 /// - Bounded by [`Self::LINE_LIMIT`] (10,000) lines produced in a single
258 /// call; exceeding it returns [`RuntimeError::LineLimitExceeded`]
259 /// rather than looping forever.
260 ///
261 /// # Errors
262 /// Any error [`step_single_line`](Self::step_single_line) itself can
263 /// produce, plus [`RuntimeError::LineLimitExceeded`] if the drive
264 /// produces [`Self::LINE_LIMIT`] lines without reaching a terminal one.
265 pub fn drive_to_terminal<R: StoryRng>(
266 &mut self,
267 program: &Program,
268 line_tables: &[Vec<brink_format::LineEntry>],
269 context: &mut (impl ContextAccess + ?Sized),
270 handler: &dyn ExternalFnHandler,
271 resolver: Option<&dyn PluralResolver>,
272 ) -> Result<Vec<Step>, RuntimeError> {
273 let mut steps = Vec::new();
274 loop {
275 let step =
276 self.step_single_line::<R>(program, line_tables, context, handler, resolver)?;
277 let terminal = step.is_terminal();
278 steps.push(step);
279 if terminal {
280 return Ok(steps);
281 }
282 if steps.len() >= Self::LINE_LIMIT {
283 return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
284 }
285 }
286 }
287
288 /// The pausable Layer-2 "drive to terminal or external pause" op:
289 /// [`drive_to_terminal`](Self::drive_to_terminal)'s sibling for callers
290 /// (e.g. `bevy-brink`) whose external bindings need to pause mid-drive
291 /// for out-of-band (world-access) resolution rather than erroring.
292 ///
293 /// Steps via [`advance`](Self::advance) instead of
294 /// [`step_single_line`](Self::step_single_line): a deferred external
295 /// yields [`DriveOutcome::AwaitingExternal`] (carrying every line
296 /// produced so far this call) instead of
297 /// [`RuntimeError::UnresolvedExternalCall`]. Resolve it and call `drive`
298 /// again to continue — the drive is logically one operation spanning
299 /// however many pauses it takes.
300 ///
301 /// `budget` is the caller-owned line budget for that whole logical
302 /// operation: each line `drive` produces (whether the call ends in
303 /// `Terminal` or `AwaitingExternal`) decrements it by one, and it is
304 /// **not** reset between calls — the caller passes the same `&mut
305 /// usize` back in on resume, so a drive spanning many external pauses
306 /// still has exactly one bound on total output, not a fresh
307 /// [`Self::LINE_LIMIT`] per resume (see the "guard against unbounded
308 /// growth" rule). Start a fresh logical drive with a fresh
309 /// `budget = FlowInstance::LINE_LIMIT` (or any caller-chosen cap).
310 ///
311 /// Like `drive_to_terminal`, the terminal step is always the last
312 /// element of the returned `Vec` and every step before it is
313 /// [`Step::Line`].
314 ///
315 /// # Errors
316 /// Any error [`advance`](Self::advance) itself can produce, plus
317 /// [`RuntimeError::LineLimitExceeded`] if `budget` reaches zero before a
318 /// terminal step is produced.
319 pub fn drive<R: StoryRng>(
320 &mut self,
321 program: &Program,
322 line_tables: &[Vec<brink_format::LineEntry>],
323 context: &mut (impl ContextAccess + ?Sized),
324 handler: &dyn ExternalFnHandler,
325 resolver: Option<&dyn PluralResolver>,
326 budget: &mut usize,
327 ) -> Result<DriveOutcome, RuntimeError> {
328 // Captured only to report a meaningful number on exhaustion: the
329 // remaining budget *this call* started with, not the (possibly
330 // already-partially-spent, across earlier resumes) original cap.
331 let starting_budget = *budget;
332 let mut steps = Vec::new();
333 loop {
334 if *budget == 0 {
335 return Err(RuntimeError::LineLimitExceeded(starting_budget));
336 }
337 match self.advance::<R>(program, line_tables, context, handler, resolver)? {
338 StepOutcome::AwaitingExternal => return Ok(DriveOutcome::AwaitingExternal(steps)),
339 StepOutcome::Step(step) => {
340 let terminal = step.is_terminal();
341 *budget -= 1;
342 steps.push(step);
343 if terminal {
344 return Ok(DriveOutcome::Terminal(steps));
345 }
346 }
347 }
348 }
349 }
350
351 /// Like [`step_single_line`](Self::step_single_line), but surfaces a
352 /// deferred external ([`ExternalResult::Pending`]) as
353 /// [`StepOutcome::AwaitingExternal`] instead of an error — so a
354 /// world-access binding hit during normal playback can pause cleanly.
355 /// Resolve the pending external and call `advance` again to continue.
356 pub fn advance<R: StoryRng>(
357 &mut self,
358 program: &Program,
359 line_tables: &[Vec<brink_format::LineEntry>],
360 context: &mut (impl ContextAccess + ?Sized),
361 handler: &dyn ExternalFnHandler,
362 resolver: Option<&dyn PluralResolver>,
363 ) -> Result<StepOutcome, RuntimeError> {
364 self.advance_with_limit::<R>(
365 program,
366 line_tables,
367 context,
368 handler,
369 resolver,
370 Self::STEP_LIMIT,
371 )
372 }
373
374 /// Like [`advance`](Self::advance), but the per-call VM step budget is
375 /// `step_limit` rather than the hardcoded [`Self::STEP_LIMIT`].
376 ///
377 /// This is what lets [`crate::Speculation::advance`] cap a single
378 /// visible-line drive at a small, caller-supplied budget instead of the
379 /// production 1,000,000-step ceiling, so a runaway speculative probe
380 /// errors quickly instead of burning a huge step budget before giving
381 /// up. `advance` itself is a thin wrapper over this with
382 /// `step_limit: Self::STEP_LIMIT` — every existing call site keeps its
383 /// exact prior behavior.
384 #[expect(clippy::too_many_lines)]
385 pub(crate) fn advance_with_limit<R: StoryRng>(
386 &mut self,
387 program: &Program,
388 line_tables: &[Vec<brink_format::LineEntry>],
389 context: &mut (impl ContextAccess + ?Sized),
390 handler: &dyn ExternalFnHandler,
391 resolver: Option<&dyn PluralResolver>,
392 step_limit: u64,
393 ) -> Result<StepOutcome, RuntimeError> {
394 // 0. A terminal was computed on the previous call but held back
395 // because its trailing content had to go out first as its own
396 // `Step::Line` (terminals carry no text — §7). Deliver it now,
397 // bare, with no VM stepping — but only if no new run has begun
398 // since it was stashed (`PendingTerminal`'s invalidation
399 // invariant, #2104): a host-directed jump or choice between the
400 // two calls bumps `next_block_id`, and `take_if_current` silently
401 // drops a stash stamped with a now-stale block id instead of
402 // replaying it.
403 if let Some(pending) = self
404 .flow
405 .pending_terminal
406 .take_if_current(self.flow.next_block_id)
407 {
408 return Ok(StepOutcome::Step(pending));
409 }
410
411 // 1. If buffer already has a completed line from a previous step,
412 // take it immediately (no VM stepping needed).
413 if self.flow.output.has_completed_line()
414 && let Some((text, tags, element)) =
415 self.flow
416 .output
417 .take_first_line(program, line_tables, resolver)
418 {
419 return Ok(StepOutcome::Step(make_output_line(
420 &self.flow, text, tags, element,
421 )));
422 }
423
424 // 2. If buffer has partial content but VM has already yielded
425 // (any non-Active state), flush it. At a yield point, no more
426 // output is coming, so trailing Newlines are committed.
427 if self.flow.output.has_unread() && self.status != StoryStatus::Active {
428 let (text, tags, element) =
429 flush_remaining(&mut self.flow, program, line_tables, resolver);
430 return Ok(StepOutcome::Step(yield_step(
431 self.status,
432 text,
433 tags,
434 element,
435 &mut self.flow,
436 program,
437 line_tables,
438 resolver,
439 )));
440 }
441
442 // 3. Status checks.
443 if self.status == StoryStatus::Ended {
444 return Err(RuntimeError::StoryEnded);
445 }
446 if self.status == StoryStatus::WaitingForChoice {
447 return Err(RuntimeError::NotWaitingForChoice);
448 }
449
450 // 4. Reset Done → Active (resuming after output).
451 // If the previous cycle ended without a safe exit (no explicit
452 // -> DONE opcode), the story ran out of content. The previous
453 // call delivered the text — error now.
454 if self.status == StoryStatus::Done {
455 if !self.flow.did_safe_exit {
456 return Err(RuntimeError::RanOutOfContent(
457 self.flow.ran_out_of_content_cause,
458 ));
459 }
460 self.status = StoryStatus::Active;
461 // A fresh run begins wherever the story resumes from `Done`.
462 self.flow.next_block_id += 1;
463 }
464
465 // Clear flags — will be set during this cycle if relevant.
466 self.flow.did_safe_exit = false;
467 self.flow.did_unsafe_yield = false;
468
469 // 5. Step VM loop.
470 let Self {
471 flow,
472 status,
473 stats,
474 ..
475 } = self;
476 let step_start = stats.steps;
477
478 loop {
479 stats.steps += 1;
480
481 if stats.steps - step_start > step_limit {
482 return Err(RuntimeError::StepLimitExceeded(step_limit));
483 }
484
485 let stepped = vm::step::<R>(flow, program, line_tables, context, stats, resolver)?;
486 stats.materializations += flow.drain_materializations();
487
488 match stepped {
489 vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {
490 if flow.output.has_completed_line()
491 && let Some((text, tags, element)) =
492 flow.output.take_first_line(program, line_tables, resolver)
493 {
494 return Ok(StepOutcome::Step(make_output_line(
495 flow, text, tags, element,
496 )));
497 }
498 }
499
500 vm::Stepped::ExternalCall => {
501 // `false` means the handler deferred (Pending): pause
502 // cleanly so the caller can resolve it out-of-band.
503 if !resolve_external_call(flow, program, handler)? {
504 return Ok(StepOutcome::AwaitingExternal);
505 }
506 if flow.output.has_completed_line()
507 && let Some((text, tags, element)) =
508 flow.output.take_first_line(program, line_tables, resolver)
509 {
510 return Ok(StepOutcome::Step(make_output_line(
511 flow, text, tags, element,
512 )));
513 }
514 }
515
516 vm::Stepped::Done => {
517 context.increment_turn_index();
518
519 // Handle invisible default choices: auto-select and keep running.
520 if !flow.pending_choices.is_empty() {
521 let all_invisible = flow
522 .pending_choices
523 .iter()
524 .all(|pc| pc.flags.is_invisible_default);
525 if all_invisible {
526 select_choice(flow, context, status, stats, 0)?;
527 if flow.output.has_completed_line()
528 && let Some((text, tags, element)) =
529 flow.output.take_first_line(program, line_tables, resolver)
530 {
531 return Ok(StepOutcome::Step(make_output_line(
532 flow, text, tags, element,
533 )));
534 }
535 continue;
536 }
537 }
538
539 // Set status based on remaining choices.
540 if flow.pending_choices.is_empty() {
541 *status = StoryStatus::Done;
542 } else {
543 *status = StoryStatus::WaitingForChoice;
544 stats.choices_presented += 1;
545 }
546
547 if flow.output.has_completed_line()
548 && let Some((text, tags, element)) =
549 flow.output.take_first_line(program, line_tables, resolver)
550 {
551 return Ok(StepOutcome::Step(make_output_line(
552 flow, text, tags, element,
553 )));
554 }
555
556 let (text, tags, element) =
557 flush_remaining(flow, program, line_tables, resolver);
558 return Ok(StepOutcome::Step(yield_step(
559 *status,
560 text,
561 tags,
562 element,
563 flow,
564 program,
565 line_tables,
566 resolver,
567 )));
568 }
569
570 vm::Stepped::Ended => {
571 context.increment_turn_index();
572 *status = StoryStatus::Ended;
573
574 if flow.output.has_completed_line()
575 && let Some((text, tags, element)) =
576 flow.output.take_first_line(program, line_tables, resolver)
577 {
578 return Ok(StepOutcome::Step(make_output_line(
579 flow, text, tags, element,
580 )));
581 }
582
583 let (text, tags, element) =
584 flush_remaining(flow, program, line_tables, resolver);
585 return Ok(StepOutcome::Step(yield_step(
586 *status,
587 text,
588 tags,
589 element,
590 flow,
591 program,
592 line_tables,
593 resolver,
594 )));
595 }
596 }
597 }
598 }
599
600 /// Select a choice by index. Call [`step_single_line`](Self::step_single_line)
601 /// afterward to continue execution from the chosen branch.
602 pub fn choose(
603 &mut self,
604 context: &mut (impl ContextAccess + ?Sized),
605 index: usize,
606 ) -> Result<(), RuntimeError> {
607 if self.status != StoryStatus::WaitingForChoice {
608 return Err(RuntimeError::NotWaitingForChoice);
609 }
610 select_choice(
611 &mut self.flow,
612 context,
613 &mut self.status,
614 &mut self.stats,
615 index,
616 )
617 }
618
619 /// Move the play head to a named knot/stitch path — the equivalent of
620 /// ink's `Story.ChoosePathString(path)` (with its default
621 /// `resetCallstack: true`). Call [`step_single_line`](Self::step_single_line)
622 /// (or any continue method) afterward to run from there.
623 ///
624 /// `path` is a dot-separated runtime path: a knot (`intro`), a qualified
625 /// stitch (`intro.dock`), or — for programs compiled by `brink-compiler` —
626 /// an author label (`knot.label`, `knot.stitch.label`; an extension over
627 /// C#, which cannot address labels).
628 ///
629 /// Mirroring the C# reference (`Story.ChoosePathString` →
630 /// `ResetCallstack`/`ForceEnd` → `ChoosePath` → `state.SetChosenPath` +
631 /// `VisitChangedContainersDueToDivert`):
632 ///
633 /// - The current flow is **force-completed** first: the call stack
634 /// collapses to a single fresh root frame (abandoning any tunnels,
635 /// threads, or in-progress weave), pending choices are cleared, and
636 /// the jump counts as a safe exit (as if the story had hit `-> DONE`).
637 /// - The jump **counts as a visit** to the target, with exactly the
638 /// semantics of an in-story `-> path` divert (it goes through the same
639 /// goto machinery, so counting flags are honored identically).
640 /// - Output already produced but not yet consumed is **kept** (C# leaves
641 /// the output stream untouched); it is delivered before content from
642 /// the new location. The value stack is likewise left as-is.
643 /// - A permanently **ended** story (`-> END`) may be re-entered by
644 /// jumping, matching C# where `ChoosePathString` + `Continue` works
645 /// after the story has ended.
646 ///
647 /// # Errors
648 /// - [`UnknownPath`](RuntimeError::UnknownPath) if `path` resolves to no
649 /// target (the message names the path).
650 /// - [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
651 /// if the flow is parked on an unresolved external call — a pending
652 /// host call must be resolved, not silently abandoned.
653 /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
654 /// if an engine→ink function evaluation is in progress (C# likewise
655 /// refuses to redirect mid-function).
656 pub fn choose_path_string(
657 &mut self,
658 program: &Program,
659 context: &mut (impl ContextAccess + ?Sized),
660 path: &str,
661 ) -> Result<(), RuntimeError> {
662 self.choose_path_string_with_args(program, context, path, &[])
663 }
664
665 /// Like [`choose_path_string`](Self::choose_path_string) but **binds the
666 /// target knot's declared parameters** from `args` — host-directed entry
667 /// into a parameterized knot/stitch (`=== call(action, present) ===`),
668 /// which a plain path jump can't reach with its params bound.
669 ///
670 /// Semantics are otherwise identical to `choose_path_string` (force-ends
671 /// the current flow, counts as a visit, etc.). The args are pushed onto the
672 /// value stack in declaration order and bound by the target's prologue —
673 /// exactly as an in-story `-> call(a, b)` divert binds them, so this enters
674 /// at the container start (where the prologue runs).
675 ///
676 /// # Errors
677 /// In addition to [`choose_path_string`](Self::choose_path_string)'s errors:
678 /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) if `args.len()`
679 /// differs from the target container's declared parameter count. (Programs
680 /// built by the converter record no param counts, so they report `0` — pass
681 /// no args.)
682 pub fn choose_path_string_with_args(
683 &mut self,
684 program: &Program,
685 context: &mut (impl ContextAccess + ?Sized),
686 path: &str,
687 args: &[Value],
688 ) -> Result<(), RuntimeError> {
689 // M-2b: refuse host-driven entry into a `#@private` knot/stitch
690 // while visibility enforcement is on (`docs/modules-spec.md` §4
691 // boundary rule 2). Mirrors `Story`'s own `check_entry_visibility`
692 // for callers that drive this `FlowInstance` directly — `bevy-brink`,
693 // `Speculation` — without going through `Story`. Checked before any
694 // other error path so a private name reports as private, not as
695 // "not found" or "awaiting external".
696 if self.enforce_visibility && program.has_private_defs() && program.path_is_private(path) {
697 return Err(RuntimeError::PrivateAccess {
698 name: path.to_owned(),
699 });
700 }
701 // A parked host call cannot be silently abandoned: erroring is the
702 // strictest safe behavior (brink-specific — C# has no pausable
703 // externals during normal playback).
704 if let Some(id) = self.flow.external_fn_id() {
705 let external = program
706 .external_fn(id)
707 .map_or_else(|| format!("{id}"), |e| program.name(e.name).to_owned());
708 return Err(RuntimeError::JumpWhileAwaitingExternal {
709 path: path.to_owned(),
710 external,
711 });
712 }
713 // An in-flight engine→ink evaluation (possibly paused on an external)
714 // must finish or be aborted before the flow can be redirected.
715 if self.eval.is_some() {
716 return Err(RuntimeError::AlreadyEvaluatingFunction);
717 }
718
719 let target_id = program
720 .find_path_target(path)
721 .ok_or_else(|| RuntimeError::UnknownPath(path.to_owned()))?;
722
723 // Arity-check before mutating any state. The target container's
724 // declared param count is what its prologue's `DeclareTemp`s will pop.
725 let expected = program.path_param_count(path).unwrap_or(0);
726 if args.len() != expected as usize {
727 return Err(RuntimeError::ArgCountMismatch {
728 target: path.to_owned(),
729 expected,
730 got: args.len(),
731 });
732 }
733
734 // Force-end the current flow, mirroring C# `ResetCallstack` →
735 // `StoryState.ForceEnd`: a single fresh root frame (callStack.Reset),
736 // cleared choices, null pointers (the empty container stack), and
737 // didSafeExit = true. The output buffer and value stack are
738 // deliberately left untouched — C# `ForceEnd` does not clear the
739 // output stream or the evaluation stack.
740 let root_frame = CallFrame {
741 return_address: None,
742 temps: Vec::new(),
743 container_stack: Vec::new(),
744 frame_type: CallFrameType::Root,
745 external_fn_id: None,
746 function_output_start: None,
747 };
748 self.flow.threads = vec![Thread {
749 call_stack: CallStack::new(root_frame),
750 }];
751 self.flow.pending_choices.clear();
752 // No explicit pending-terminal clear needed here: `next_block_id`'s
753 // bump below (a fresh run begins at the jump target) is exactly
754 // what invalidates any stash from before the jump — see
755 // `PendingTerminal`'s doc comment. The host explicitly redirected
756 // execution, so the next `advance`/`step_single_line` call correctly
757 // steps the VM at the new target rather than handing back a stale
758 // `Done`/`Choices`/`End` left over from before the jump.
759 // Transient intra-step flags. Both are false at any point a host can
760 // observe (between lines / at a yield), but the jump abandons whatever
761 // produced them, so clear defensively.
762 self.flow.skipping_choice = false;
763 self.flow.in_tag = false;
764 self.flow.did_safe_exit = true;
765 // The jump force-completes the current flow like `-> DONE` (see
766 // this method's own doc comment) — a fresh run begins at the
767 // target (`BlockId`, §3.7/§8d.2).
768 self.flow.next_block_id += 1;
769
770 // Push the arguments in declaration order; the target's prologue
771 // (`DeclareTemp`) binds them, exactly as `begin_function_eval` and an
772 // in-story `-> call(a, b)` divert do.
773 self.flow.value_stack.extend_from_slice(args);
774
775 // Jump via the same divert machinery as an in-story `-> path`
776 // (mirrors C# `ChoosePath` → `SetChosenPath` +
777 // `VisitChangedContainersDueToDivert`): sets the position and
778 // increments the target's visit/turn counts per its counting flags.
779 vm::goto_target(&mut self.flow, program, context, target_id)?;
780
781 self.status = StoryStatus::Active;
782 Ok(())
783 }
784
785 /// The current execution status of this flow.
786 #[must_use]
787 pub fn status(&self) -> StoryStatus {
788 self.status
789 }
790
791 /// Whether the most recent execution cycle ended with a *safe exit* —
792 /// an explicit `-> DONE` opcode — as opposed to falling off the end of
793 /// its content with nothing left to run.
794 ///
795 /// Both cases deliver a terminal [`Step::Done`]; this is the only way
796 /// to tell them apart without issuing an extra `advance`/
797 /// `step_single_line` call and observing whether it returns
798 /// [`RuntimeError::RanOutOfContent`](crate::RuntimeError::RanOutOfContent).
799 /// Read it right after receiving a `Step::Done` — it is cleared at the
800 /// start of the *next* execution cycle, so a value read before a
801 /// terminal step is not meaningful.
802 ///
803 /// `true`: the story chose to stop (a knot/stitch reached `-> DONE`);
804 /// resuming later is well-formed. `false`: the flow ran out of
805 /// content; the trailing text was still delivered, but resuming will
806 /// fault.
807 #[must_use]
808 pub fn did_safe_exit(&self) -> bool {
809 self.flow.did_safe_exit
810 }
811
812 /// Runtime statistics (instructions, materialization counts, etc.)
813 /// accumulated over this flow's execution.
814 #[must_use]
815 pub fn stats(&self) -> &Stats {
816 &self.stats
817 }
818
819 /// The full append-only transcript of all output parts produced so far.
820 ///
821 /// The transcript stores structural references (e.g. `LineRef`) rather
822 /// than resolved strings, so it can be re-rendered in any locale by
823 /// passing a different set of line tables to
824 /// [`transcript::render_transcript`](crate::transcript::render_transcript).
825 #[must_use]
826 pub fn transcript(&self) -> &[crate::output::OutputPart] {
827 self.flow.output.transcript()
828 }
829
830 /// Number of parts in the transcript.
831 #[must_use]
832 pub fn transcript_len(&self) -> usize {
833 self.flow.output.transcript_len()
834 }
835
836 /// Reset the transcript read cursor to the beginning (for re-rendering,
837 /// e.g. after a locale swap).
838 pub fn reset_cursor(&mut self) {
839 self.flow.output.reset_cursor();
840 }
841
842 /// The fragments captured during execution (for re-rendering choice
843 /// display text and computed substrings in a different locale).
844 #[must_use]
845 pub fn fragments(&self) -> &[crate::output::Fragment] {
846 self.flow.output.fragments()
847 }
848
849 // ── External calls (ink → engine) ────────────────────────────────
850
851 /// Returns `true` if this flow is frozen on an unresolved external
852 /// call — i.e. the VM hit a `CallExternal` opcode and the handler
853 /// returned [`ExternalResult::Pending`], leaving the `External` frame
854 /// on top of the call stack.
855 ///
856 /// The orchestration layer (e.g. a Bevy resolver system) polls this to
857 /// decide whether the flow needs an external resolved before it can be
858 /// driven further. Resolve via [`resolve_external`](Self::resolve_external).
859 #[must_use]
860 pub fn has_pending_external(&self) -> bool {
861 self.flow.external_fn_id().is_some()
862 }
863
864 /// The [`DefinitionId`] of the pending external function, if this flow
865 /// is frozen on one. Returns `None` otherwise.
866 #[must_use]
867 pub fn pending_external_fn_id(&self) -> Option<DefinitionId> {
868 self.flow.external_fn_id()
869 }
870
871 /// The arguments to the pending external call, in declaration order.
872 /// Empty if no external call is pending.
873 #[must_use]
874 pub fn pending_external_args(&self) -> &[Value] {
875 self.flow.external_args()
876 }
877
878 /// The ink-declared name of the pending external function, resolved
879 /// against `program`'s name table. Returns `None` if no external is
880 /// pending (or the entry is missing, which would indicate a malformed
881 /// program).
882 ///
883 /// The orchestration layer uses this to look up the binding registered
884 /// for this name.
885 #[must_use]
886 pub fn pending_external_name<'p>(&self, program: &'p Program) -> Option<&'p str> {
887 let id = self.flow.external_fn_id()?;
888 let entry = program.external_fn(id)?;
889 Some(program.name(entry.name))
890 }
891
892 /// Resolve a pending external call by supplying its return value. Pops
893 /// the `External` frame and pushes `value` onto the value stack so the
894 /// VM can resume. For fire-and-forget externals, pass [`Value::Null`].
895 ///
896 /// No-op if no external call is pending. After resolving, drive the
897 /// flow forward with [`step_single_line`](Self::step_single_line).
898 pub fn resolve_external(&mut self, value: Value) {
899 self.flow.resolve_external(value);
900 }
901
902 // ── Engine → ink calls ───────────────────────────────────────────
903
904 /// Evaluate an ink function from engine code, returning its value.
905 ///
906 /// This does **not** advance the player-visible story: a
907 /// `FunctionEvalFromGame` boundary frame is pushed, `args` are passed
908 /// in declaration order (exactly as a normal call site would), output
909 /// is captured and discarded, and the function runs until it returns.
910 ///
911 /// If the function calls an external whose handler returns
912 /// [`ExternalResult::Pending`] (e.g. a binding that needs Bevy World
913 /// access), evaluation pauses and returns
914 /// [`FunctionEval::AwaitingExternal`]; the caller resolves the
915 /// external (see [`resolve_external`](Self::resolve_external)) and
916 /// calls [`resume_function_eval`](Self::resume_function_eval).
917 ///
918 /// `container_idx` is the function's container, typically obtained from
919 /// [`Program::find_address`](crate::Program::find_address) on the
920 /// function name. Unlike a normal `Call`, this does not increment the
921 /// function's visit count — an engine query is out-of-band, matching
922 /// C#'s `EvaluateFunction`.
923 ///
924 /// # Errors
925 /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
926 /// if a function evaluation is already in progress on this flow.
927 /// - [`FunctionYielded`](RuntimeError::FunctionYielded) if the function
928 /// presents choices or ends the story (functions must not yield).
929 /// - [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall)
930 /// if an external has neither a binding nor a fallback.
931 #[expect(
932 clippy::too_many_arguments,
933 reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target and args"
934 )]
935 pub fn begin_function_eval<R: StoryRng>(
936 &mut self,
937 program: &Program,
938 line_tables: &[Vec<brink_format::LineEntry>],
939 context: &mut (impl ContextAccess + ?Sized),
940 handler: &dyn ExternalFnHandler,
941 container_idx: u32,
942 args: &[Value],
943 resolver: Option<&dyn PluralResolver>,
944 ) -> Result<FunctionEval, RuntimeError> {
945 self.begin_function_eval_with_limit::<R>(
946 program,
947 line_tables,
948 context,
949 handler,
950 container_idx,
951 args,
952 resolver,
953 Self::STEP_LIMIT,
954 )
955 }
956
957 /// Like [`begin_function_eval`](Self::begin_function_eval), but the VM
958 /// step budget for the whole evaluation is `step_limit` rather than the
959 /// hardcoded [`Self::STEP_LIMIT`] (#1868).
960 ///
961 /// This is what lets a caller give an engine→ink evaluation its own,
962 /// appropriately scoped budget — e.g. a compile-time registry walk,
963 /// which wants a small ceiling of its own rather than the 1,000,000-step
964 /// production default — mirroring how [`advance_with_limit`](Self::advance_with_limit)
965 /// already lets [`crate::Speculation`] cap the line-stepping path.
966 /// `begin_function_eval` itself is a thin wrapper over this with
967 /// `step_limit: Self::STEP_LIMIT` — every existing call site keeps its
968 /// exact prior behavior.
969 ///
970 /// # Errors
971 /// Same as [`begin_function_eval`](Self::begin_function_eval), plus
972 /// [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now bounded
973 /// by the caller-supplied `step_limit` rather than the fixed default.
974 #[expect(
975 clippy::too_many_arguments,
976 reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target, args, and step_limit"
977 )]
978 pub fn begin_function_eval_with_limit<R: StoryRng>(
979 &mut self,
980 program: &Program,
981 line_tables: &[Vec<brink_format::LineEntry>],
982 context: &mut (impl ContextAccess + ?Sized),
983 handler: &dyn ExternalFnHandler,
984 container_idx: u32,
985 args: &[Value],
986 resolver: Option<&dyn PluralResolver>,
987 step_limit: u64,
988 ) -> Result<FunctionEval, RuntimeError> {
989 // M-2b: refuse host-driven evaluation of a `#@private` function
990 // while visibility enforcement is on (`docs/modules-spec.md` §4
991 // boundary rule 2). Mirrors `Story::call_function`'s own check for
992 // callers that drive this `FlowInstance` directly — `bevy-brink`,
993 // `Speculation` — without going through `Story`. The caller
994 // resolves `container_idx` itself (typically via
995 // [`Program::find_address`](crate::Program::find_address) on the
996 // function name), so the error names the definition by its compiled
997 // id rather than the original name string, which isn't available
998 // here.
999 if self.enforce_visibility
1000 && program.has_private_defs()
1001 && program.container_is_private(container_idx)
1002 {
1003 return Err(RuntimeError::PrivateAccess {
1004 name: format!("{}", program.container(container_idx).id),
1005 });
1006 }
1007 if self.eval.is_some() {
1008 return Err(RuntimeError::AlreadyEvaluatingFunction);
1009 }
1010
1011 // Record floors BEFORE pushing args: the value-stack length (so the
1012 // return value and any leftover args can be reclaimed), and the
1013 // pending-choice count (so we can tell a choice the function
1014 // presents from choices the main story already has waiting).
1015 let value_floor = self.flow.value_stack.len();
1016 let choice_floor = self.flow.pending_choices.len();
1017
1018 // Isolate output: anything the function emits routes to the
1019 // capture scratch space and never reaches the transcript.
1020 self.flow.output.begin_capture();
1021
1022 let output_start = self.flow.output.target_len();
1023 let boundary = CallFrame {
1024 return_address: None,
1025 temps: Vec::new(),
1026 container_stack: vec![ContainerPosition {
1027 container_idx,
1028 offset: 0,
1029 }],
1030 frame_type: CallFrameType::FunctionEvalFromGame,
1031 external_fn_id: None,
1032 function_output_start: Some(output_start),
1033 };
1034 self.flow.current_thread_mut().call_stack.push(boundary);
1035 self.stats.frames_pushed += 1;
1036
1037 // Pass arguments onto the value stack in declaration order — the
1038 // function's prologue (`DeclareTemp`) binds them exactly as it
1039 // would for an in-story call.
1040 self.flow.value_stack.extend_from_slice(args);
1041
1042 self.eval = Some(EvalState {
1043 value_floor,
1044 choice_floor,
1045 });
1046 self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1047 }
1048
1049 /// Evaluate an ink **function value** (`FnRef`/`Closure`) from engine
1050 /// code — the host callback-invocation surface (T1c-3,
1051 /// `docs/t1c-spec.md` §6). A function value crosses to the host as an
1052 /// opaque token `{DefinitionId, env}`; the host never dereferences the
1053 /// env — invocation always re-enters the VM here and is journaled
1054 /// exactly like [`begin_function_eval`](Self::begin_function_eval).
1055 ///
1056 /// `callee` must be a [`Value::FnRef`] / [`Value::Closure`]; `args`
1057 /// supply the remaining (val-only) params after the value's bound
1058 /// prefix. The same fault set as in-story dispatch applies — non-function
1059 /// callee, wrong arity, rehydration mismatch, cross-flow ref-`#@local`
1060 /// (`docs/t1c-spec.md` §3/§6) — surfaced as the `Err` here rather than as
1061 /// a turn-terminating story fault, since this is out-of-band evaluation.
1062 ///
1063 /// Like [`begin_function_eval`](Self::begin_function_eval) this does not
1064 /// advance the player-visible story (output isolated, transcript
1065 /// untouched, no visit-count increment) and pauses on world-access
1066 /// externals — resume with
1067 /// [`resume_function_eval`](Self::resume_function_eval).
1068 ///
1069 /// # Errors
1070 /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1071 /// if an evaluation is already in progress on this flow.
1072 /// - The function-value dispatch faults above (via
1073 /// `vm::prepare_fn_value_call`), before any frame is pushed.
1074 /// - The same evaluation errors as
1075 /// [`begin_function_eval`](Self::begin_function_eval).
1076 #[expect(
1077 clippy::too_many_arguments,
1078 reason = "mirrors begin_function_eval: the VM environment plus the callee value and args"
1079 )]
1080 pub fn begin_function_value_eval<R: StoryRng>(
1081 &mut self,
1082 program: &Program,
1083 line_tables: &[Vec<brink_format::LineEntry>],
1084 context: &mut (impl ContextAccess + ?Sized),
1085 handler: &dyn ExternalFnHandler,
1086 callee: &Value,
1087 args: &[Value],
1088 resolver: Option<&dyn PluralResolver>,
1089 ) -> Result<FunctionEval, RuntimeError> {
1090 self.begin_function_value_eval_with_limit::<R>(
1091 program,
1092 line_tables,
1093 context,
1094 handler,
1095 callee,
1096 args,
1097 resolver,
1098 Self::STEP_LIMIT,
1099 )
1100 }
1101
1102 /// Like [`begin_function_value_eval`](Self::begin_function_value_eval),
1103 /// but the VM step budget for the whole evaluation is `step_limit`
1104 /// rather than the hardcoded [`Self::STEP_LIMIT`] (#1868) — the function-value
1105 /// sibling of [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit).
1106 ///
1107 /// # Errors
1108 /// Same as [`begin_function_value_eval`](Self::begin_function_value_eval),
1109 /// plus [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now
1110 /// bounded by the caller-supplied `step_limit` rather than the fixed
1111 /// default.
1112 #[expect(
1113 clippy::too_many_arguments,
1114 reason = "mirrors begin_function_eval_with_limit: the VM environment plus the callee value, args, and step_limit"
1115 )]
1116 pub fn begin_function_value_eval_with_limit<R: StoryRng>(
1117 &mut self,
1118 program: &Program,
1119 line_tables: &[Vec<brink_format::LineEntry>],
1120 context: &mut (impl ContextAccess + ?Sized),
1121 handler: &dyn ExternalFnHandler,
1122 callee: &Value,
1123 args: &[Value],
1124 resolver: Option<&dyn PluralResolver>,
1125 step_limit: u64,
1126 ) -> Result<FunctionEval, RuntimeError> {
1127 if self.eval.is_some() {
1128 return Err(RuntimeError::AlreadyEvaluatingFunction);
1129 }
1130
1131 // Validate + assemble the full arg row (bound prefix then supplied)
1132 // through the shared dispatch path, so a bad callee faults *before*
1133 // any boundary frame or capture scope is set up — no partial state.
1134 let (container_idx, _target, full_args) =
1135 vm::prepare_fn_value_call(program, callee, args.to_vec())?;
1136
1137 // M-2b: refuse a `#@private` function value the same way
1138 // `begin_function_eval` refuses a `#@private` name — this is the
1139 // sibling engine→ink call-dispatch path (T1c function values), sharing
1140 // the same `container_idx` resolve-then-enter shape, so it shares the
1141 // same gap and the same fix. Checked before any boundary frame or
1142 // capture scope is set up, same as the `eval.is_some()` check above.
1143 if self.enforce_visibility
1144 && program.has_private_defs()
1145 && program.container_is_private(container_idx)
1146 {
1147 return Err(RuntimeError::PrivateAccess {
1148 name: format!("{}", program.container(container_idx).id),
1149 });
1150 }
1151
1152 let value_floor = self.flow.value_stack.len();
1153 let choice_floor = self.flow.pending_choices.len();
1154
1155 self.flow.output.begin_capture();
1156 let output_start = self.flow.output.target_len();
1157 let boundary = CallFrame {
1158 return_address: None,
1159 temps: Vec::new(),
1160 container_stack: vec![ContainerPosition {
1161 container_idx,
1162 offset: 0,
1163 }],
1164 frame_type: CallFrameType::FunctionEvalFromGame,
1165 external_fn_id: None,
1166 function_output_start: Some(output_start),
1167 };
1168 self.flow.current_thread_mut().call_stack.push(boundary);
1169 self.stats.frames_pushed += 1;
1170
1171 // Pass the full arg row (bound prefix then supplied) onto the value
1172 // stack in declaration order — the prologue binds it exactly as an
1173 // in-story call would.
1174 self.flow.value_stack.extend_from_slice(&full_args);
1175
1176 self.eval = Some(EvalState {
1177 value_floor,
1178 choice_floor,
1179 });
1180 self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1181 }
1182
1183 /// Resume a function evaluation that paused on
1184 /// [`FunctionEval::AwaitingExternal`], after the pending external has
1185 /// been resolved via [`resolve_external`](Self::resolve_external).
1186 ///
1187 /// # Errors
1188 /// - [`NotEvaluatingFunction`](RuntimeError::NotEvaluatingFunction) if
1189 /// no evaluation is in progress.
1190 /// - Same evaluation errors as
1191 /// [`begin_function_eval`](Self::begin_function_eval).
1192 pub fn resume_function_eval<R: StoryRng>(
1193 &mut self,
1194 program: &Program,
1195 line_tables: &[Vec<brink_format::LineEntry>],
1196 context: &mut (impl ContextAccess + ?Sized),
1197 handler: &dyn ExternalFnHandler,
1198 resolver: Option<&dyn PluralResolver>,
1199 ) -> Result<FunctionEval, RuntimeError> {
1200 self.resume_function_eval_with_limit::<R>(
1201 program,
1202 line_tables,
1203 context,
1204 handler,
1205 resolver,
1206 Self::STEP_LIMIT,
1207 )
1208 }
1209
1210 /// Like [`resume_function_eval`](Self::resume_function_eval), but the VM
1211 /// step budget for the remainder of the evaluation is `step_limit`
1212 /// rather than the hardcoded [`Self::STEP_LIMIT`] (#1868). A caller that
1213 /// began the evaluation with
1214 /// [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit)/
1215 /// [`begin_function_value_eval_with_limit`](Self::begin_function_value_eval_with_limit)
1216 /// should resume with the same `step_limit` to keep one consistent
1217 /// budget across pauses — this call's step count starts fresh (mirrors
1218 /// [`advance_with_limit`](Self::advance_with_limit): each call gets its
1219 /// own `step_limit`-sized allowance, not a running total).
1220 ///
1221 /// # Errors
1222 /// Same as [`resume_function_eval`](Self::resume_function_eval), plus
1223 /// [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now bounded
1224 /// by the caller-supplied `step_limit` rather than the fixed default.
1225 pub fn resume_function_eval_with_limit<R: StoryRng>(
1226 &mut self,
1227 program: &Program,
1228 line_tables: &[Vec<brink_format::LineEntry>],
1229 context: &mut (impl ContextAccess + ?Sized),
1230 handler: &dyn ExternalFnHandler,
1231 resolver: Option<&dyn PluralResolver>,
1232 step_limit: u64,
1233 ) -> Result<FunctionEval, RuntimeError> {
1234 if self.eval.is_none() {
1235 return Err(RuntimeError::NotEvaluatingFunction);
1236 }
1237 self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1238 }
1239
1240 /// Returns `true` if a function evaluation is in progress (possibly
1241 /// paused awaiting an external).
1242 #[must_use]
1243 pub fn is_evaluating_function(&self) -> bool {
1244 self.eval.is_some()
1245 }
1246
1247 /// Step the VM until the in-progress function evaluation returns or
1248 /// pauses on a pending external. Shared by `begin`/`resume`. `step_limit`
1249 /// bounds this call's own step loop (#1868) — see
1250 /// [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit)
1251 /// for why a caller-supplied budget matters here.
1252 fn drive_function_eval<R: StoryRng>(
1253 &mut self,
1254 program: &Program,
1255 line_tables: &[Vec<brink_format::LineEntry>],
1256 context: &mut (impl ContextAccess + ?Sized),
1257 handler: &dyn ExternalFnHandler,
1258 resolver: Option<&dyn PluralResolver>,
1259 step_limit: u64,
1260 ) -> Result<FunctionEval, RuntimeError> {
1261 let step_start = self.stats.steps;
1262 loop {
1263 self.stats.steps += 1;
1264 if self.stats.steps - step_start > step_limit {
1265 self.abort_eval(program, line_tables, resolver);
1266 return Err(RuntimeError::StepLimitExceeded(step_limit));
1267 }
1268
1269 let stepped = vm::step::<R>(
1270 &mut self.flow,
1271 program,
1272 line_tables,
1273 context,
1274 &mut self.stats,
1275 resolver,
1276 )?;
1277 self.stats.materializations += self.flow.drain_materializations();
1278
1279 match stepped {
1280 vm::Stepped::Done | vm::Stepped::Ended => {
1281 // A function reached `-> DONE`/`-> END` — illegal.
1282 self.abort_eval(program, line_tables, resolver);
1283 return Err(RuntimeError::FunctionYielded);
1284 }
1285 vm::Stepped::ExternalCall => {
1286 if let Some(pending) =
1287 self.resolve_eval_external(program, line_tables, resolver, handler)?
1288 {
1289 return Ok(pending);
1290 }
1291 }
1292 vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {}
1293 }
1294
1295 // Did the boundary frame pop? Then the function has returned
1296 // (via `~ return` or implicit exhaustion).
1297 if !self.flow.has_eval_boundary() {
1298 let _captured = self.flow.output.end_capture(program, line_tables, resolver);
1299 let floor = self.eval.take().map_or(0, |e| e.value_floor);
1300 let mut ret: Option<Value> = None;
1301 while self.flow.value_stack.len() > floor {
1302 let v = self.flow.value_stack.pop();
1303 if ret.is_none() {
1304 ret = v; // first popped = top of stack = the return value
1305 }
1306 }
1307 return Ok(FunctionEval::Returned(ret.unwrap_or(Value::Null)));
1308 }
1309
1310 // A function must not present choices. Compare against the
1311 // count when the eval began — the main story may already have
1312 // choices waiting, which are none of our concern.
1313 let choice_floor = self.eval.as_ref().map_or(0, |e| e.choice_floor);
1314 if self.flow.pending_choices.len() > choice_floor {
1315 self.abort_eval(program, line_tables, resolver);
1316 return Err(RuntimeError::FunctionYielded);
1317 }
1318 }
1319 }
1320
1321 /// Resolve an external hit during function evaluation, mirroring the
1322 /// normal step path but surfacing [`ExternalResult::Pending`] as
1323 /// [`FunctionEval::AwaitingExternal`] (returned as `Some`) rather than
1324 /// an error. Returns `None` when the external resolved and stepping
1325 /// should continue.
1326 fn resolve_eval_external(
1327 &mut self,
1328 program: &Program,
1329 line_tables: &[Vec<brink_format::LineEntry>],
1330 resolver: Option<&dyn PluralResolver>,
1331 handler: &dyn ExternalFnHandler,
1332 ) -> Result<Option<FunctionEval>, RuntimeError> {
1333 let fn_id = self
1334 .flow
1335 .external_fn_id()
1336 .ok_or(RuntimeError::CallStackUnderflow)?;
1337 let entry = program.external_fn(fn_id);
1338 let fn_name = entry.map_or("?", |e| program.name(e.name));
1339 match handler.call(fn_name, self.flow.external_args()) {
1340 ExternalResult::Resolved(value) => {
1341 self.flow.resolve_external(value);
1342 Ok(None)
1343 }
1344 ExternalResult::Fallback => {
1345 if let Some(fb_id) = entry.and_then(|e| e.fallback) {
1346 let container_idx = program
1347 .resolve_target(fb_id)
1348 .map(|(idx, _)| idx)
1349 .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1350 self.flow.invoke_fallback(container_idx);
1351 Ok(None)
1352 } else {
1353 self.abort_eval(program, line_tables, resolver);
1354 Err(RuntimeError::UnresolvedExternalCall(fn_id))
1355 }
1356 }
1357 ExternalResult::Pending => Ok(Some(FunctionEval::AwaitingExternal)),
1358 }
1359 }
1360
1361 /// Tear down an aborted/failed evaluation: end the output capture and
1362 /// clear the eval marker. Leaves the call stack as-is (the caller is
1363 /// erroring out).
1364 pub(crate) fn abort_eval(
1365 &mut self,
1366 program: &Program,
1367 line_tables: &[Vec<brink_format::LineEntry>],
1368 resolver: Option<&dyn PluralResolver>,
1369 ) {
1370 if self.eval.take().is_some() {
1371 let _ = self.flow.output.end_capture(program, line_tables, resolver);
1372 }
1373 }
1374}
1375
1376/// Internal: set execution position to the given choice target, clear
1377/// pending choices, and set status to Active. No status precondition.
1378#[expect(clippy::similar_names)]
1379/// Returns the `DefinitionId` of the selected choice target, so the
1380/// caller can notify observers if needed.
1381fn select_choice(
1382 flow: &mut Flow,
1383 context: &mut (impl ContextAccess + ?Sized),
1384 status: &mut StoryStatus,
1385 stats: &mut Stats,
1386 index: usize,
1387) -> Result<(), RuntimeError> {
1388 let available = flow.pending_choices.len();
1389 if index >= available {
1390 return Err(RuntimeError::InvalidChoiceIndex { index, available });
1391 }
1392
1393 let choice = flow.pending_choices.swap_remove(index);
1394 let target_id = choice.target_id;
1395
1396 // Increment visit count for the choice target container so that
1397 // once-only choices can be filtered on subsequent passes.
1398 context.increment_visit(target_id);
1399 context.set_turn_count(target_id, context.turn_index());
1400
1401 // Replace the current thread with the fork from choice creation
1402 // time. By selection time, all spawned threads should have
1403 // completed — only the main thread remains.
1404 let current = flow.current_thread_mut();
1405 *current = choice.thread_fork;
1406
1407 // Set execution position to the choice target. We reset the top
1408 // frame's container_stack to just the target — the snapshot may
1409 // have captured stale nesting from inside the choice eval block.
1410 let frame = current
1411 .call_stack
1412 .last_mut()
1413 .ok_or(RuntimeError::CallStackUnderflow)?;
1414
1415 frame.container_stack.clear();
1416 frame.container_stack.push(ContainerPosition {
1417 container_idx: choice.target_idx,
1418 offset: choice.target_offset,
1419 });
1420
1421 flow.pending_choices.clear();
1422 // No explicit pending-terminal clear needed here either (same reasoning
1423 // as `choose_path_string_with_args`): `next_block_id`'s bump below moves
1424 // this choice to a fresh run, which is exactly what `PendingTerminal`
1425 // uses to invalidate a stash from before the choice — so the next step
1426 // correctly runs the VM at the chosen target rather than replaying
1427 // whatever `Done`/`Choices`/`End` was pending before selection.
1428 *status = StoryStatus::Active;
1429 stats.choices_selected += 1;
1430 // A fresh run begins at the chosen branch (`BlockId`, §3.7/§8d.2).
1431 flow.next_block_id += 1;
1432
1433 Ok(())
1434}
1435
1436/// Resolve an external function call using the handler and program metadata.
1437///
1438/// Returns `Ok(true)` if the call was resolved (a value was supplied or the
1439/// in-story fallback was invoked) and stepping should continue; `Ok(false)`
1440/// if the handler deferred ([`ExternalResult::Pending`]), leaving the
1441/// `External` frame intact for the caller to resolve out-of-band. Errors
1442/// only when the handler declined and no fallback exists.
1443fn resolve_external_call(
1444 flow: &mut Flow,
1445 program: &Program,
1446 handler: &dyn ExternalFnHandler,
1447) -> Result<bool, RuntimeError> {
1448 let fn_id = flow
1449 .external_fn_id()
1450 .ok_or(RuntimeError::CallStackUnderflow)?;
1451
1452 let entry = program.external_fn(fn_id);
1453 let fn_name = entry.map_or("?", |e| program.name(e.name));
1454
1455 let result = handler.call(fn_name, flow.external_args());
1456 match result {
1457 ExternalResult::Resolved(value) => {
1458 flow.resolve_external(value);
1459 Ok(true)
1460 }
1461 ExternalResult::Fallback => {
1462 let fallback_id = entry.and_then(|e| e.fallback);
1463 if let Some(fb_id) = fallback_id {
1464 let container_idx = program
1465 .resolve_target(fb_id)
1466 .map(|(idx, _)| idx)
1467 .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1468
1469 flow.invoke_fallback(container_idx);
1470 Ok(true)
1471 } else {
1472 Err(RuntimeError::UnresolvedExternalCall(fn_id))
1473 }
1474 }
1475 ExternalResult::Pending => {
1476 // Leave the External frame intact — the caller resolves it
1477 // out-of-band (via resolve_external) before continuing.
1478 Ok(false)
1479 }
1480 }
1481}
1482
1483/// Flush remaining output buffer content into `(text, tags, element_data)`.
1484///
1485/// At a yield point (Done/Choices/Ended), no more output is coming, so
1486/// trailing newlines are committed. Lines are joined with `\n`, tags are
1487/// flattened into a single vec, and element-attachment data (issue #2108) is
1488/// merged the same way — later lines' keys win on conflict, matching the
1489/// existing "just flatten" precision this function already had for tags:
1490/// multiple flushed-at-once lines belonging to genuinely different attach
1491/// runs is a pre-existing imprecision this fix does not newly introduce.
1492fn flush_remaining(
1493 flow: &mut Flow,
1494 program: &Program,
1495 line_tables: &[Vec<brink_format::LineEntry>],
1496 resolver: Option<&dyn brink_format::PluralResolver>,
1497) -> (String, Vec<String>, BTreeMap<String, String>) {
1498 let lines = flow.output.flush_lines(program, line_tables, resolver);
1499 let mut text = String::new();
1500 let mut tags = Vec::new();
1501 let mut element = BTreeMap::new();
1502 for (i, (line_text, line_tags, line_element)) in lines.iter().enumerate() {
1503 if i > 0 {
1504 text.push('\n');
1505 }
1506 text.push_str(line_text);
1507 tags.extend_from_slice(line_tags);
1508 element.extend(line_element.iter().map(|(k, v)| (k.clone(), v.clone())));
1509 }
1510 (text, tags, element)
1511}
1512
1513/// Build a [`Step::Line`] stamped with the flow's current [`BlockId`] and
1514/// its [`Element`] classification.
1515///
1516/// Issue #2108 (`docs/decision-log.md` 2026-08-03 "The element output
1517/// model") populates `element.data` from `data` — the per-line element-
1518/// attachment snapshot [`OutputBuffer::take_first_line`]/`flush_lines`
1519/// already resolved from the output buffer's own transcript (see
1520/// `OutputPart::ElementAttach`'s doc for why it lives there rather than on
1521/// `Flow`). The common case — no attach convention preceded this line —
1522/// passes an empty map, falling back to the always-correct
1523/// [`Element::narrative`] default, unchanged from #1683.
1524///
1525/// `element.kind` stays [`Element::NARRATIVE`] either way: only *data* is
1526/// populated here. Classifying `kind` itself for a non-attach single-line
1527/// handler (`heading`/`transition` reporting their own handler name) is a
1528/// distinct, separately-tractable gap this PR does not close — see
1529/// `docs/decision-log.md`/this issue's follow-up notes.
1530fn make_output_line(
1531 flow: &Flow,
1532 text: String,
1533 tags: Vec<String>,
1534 data: BTreeMap<String, String>,
1535) -> Step {
1536 let element = if data.is_empty() {
1537 Element::narrative()
1538 } else {
1539 Element {
1540 kind: Element::NARRATIVE.to_string(),
1541 data,
1542 }
1543 };
1544 Step::Line(OutputLine {
1545 text,
1546 tags,
1547 block_id: BlockId(flow.next_block_id),
1548 element,
1549 })
1550}
1551
1552/// Collect the currently pending choices into the public [`Choice`] shape,
1553/// resolving each display text (trimming spaces/tabs, matching C#:
1554/// `choice.text = (startText + choiceOnlyText).Trim(' ', '\t')`).
1555fn collect_choices(
1556 flow: &Flow,
1557 program: &Program,
1558 line_tables: &[Vec<brink_format::LineEntry>],
1559 resolver: Option<&dyn brink_format::PluralResolver>,
1560) -> Vec<Choice> {
1561 flow.pending_choices
1562 .iter()
1563 .enumerate()
1564 .filter(|(_, pc)| !pc.flags.is_invisible_default)
1565 .map(|(i, pc)| {
1566 let display_text = match &pc.display {
1567 ChoiceDisplay::Text(s) => s.clone(),
1568 ChoiceDisplay::Fragment(idx) => {
1569 flow.output
1570 .resolve_fragment(*idx, program, line_tables, resolver)
1571 }
1572 };
1573 let display_text = display_text
1574 .trim_matches(|c: char| c == ' ' || c == '\t')
1575 .to_string();
1576 Choice {
1577 text: display_text,
1578 index: i,
1579 tags: pc.tags.clone(),
1580 }
1581 })
1582 .collect()
1583}
1584
1585/// Build the terminal [`Step`] for a yield point (`WaitingForChoice`/
1586/// `Done`/`Ended`) based on the current story status.
1587///
1588/// Terminals carry no text (`docs/prose-dialect-spec.md` §7, RULED) —
1589/// `Line`'s old fused shape no longer exists. If `text`/`tags` is
1590/// non-empty, it's delivered first as its own `Step::Line`, and the bare
1591/// terminal is stashed on `flow.pending_terminal` for the very next
1592/// `advance` call to return with no further VM stepping. If there's
1593/// nothing to flush, the bare terminal is returned immediately.
1594#[expect(
1595 clippy::too_many_arguments,
1596 reason = "issue #2108's `element` param pushed this past 7; each param is \
1597 a distinct piece of the terminal/line it builds, not a natural group"
1598)]
1599fn yield_step(
1600 status: StoryStatus,
1601 text: String,
1602 tags: Vec<String>,
1603 element: BTreeMap<String, String>,
1604 flow: &mut Flow,
1605 program: &Program,
1606 line_tables: &[Vec<brink_format::LineEntry>],
1607 resolver: Option<&dyn brink_format::PluralResolver>,
1608) -> Step {
1609 let terminal = match status {
1610 StoryStatus::WaitingForChoice => {
1611 Step::Choices(collect_choices(flow, program, line_tables, resolver))
1612 }
1613 StoryStatus::Ended => Step::End,
1614 StoryStatus::Done => Step::Done,
1615 // Defensive fallback — `yield_step` is only ever called once
1616 // `status` has transitioned away from `Active` at a genuine yield
1617 // point (see call sites), so this arm is unreachable in practice.
1618 StoryStatus::Active => return make_output_line(flow, text, tags, element),
1619 };
1620
1621 if text.is_empty() && tags.is_empty() {
1622 terminal
1623 } else {
1624 flow.pending_terminal.stash(flow.next_block_id, terminal);
1625 make_output_line(flow, text, tags, element)
1626 }
1627}