Skip to main content

brink_runtime/story/
call_stack.rs

1//! Low-level flow mechanics: [`CallStack`], [`Flow`], and their supporting
2//! types (call frames, threads, pending choices).
3
4use alloc::string::String;
5use alloc::sync::Arc;
6use alloc::vec;
7use alloc::vec::Vec;
8
9use brink_format::{ChoiceFlags, DefinitionId, Value};
10
11use crate::error::{RanOutOfContentCause, RuntimeError};
12use crate::output::OutputBuffer;
13
14// ── Internal types ──────────────────────────────────────────────────────────
15
16#[derive(Debug, Clone, Copy)]
17pub(crate) struct ContainerPosition {
18    pub container_idx: u32,
19    pub offset: usize,
20}
21
22/// Distinguishes call frame types for container-stack-empty semantics:
23///
24/// - **Root**: the initial frame. Yields for pending choices.
25/// - **Function**: `f()` calls. Output is captured as a return value.
26/// - **Tunnel**: `->t->` calls. Yields for pending choices (the tunnel
27///   needs the player's choice before it can continue).
28/// - **Thread**: boundary frame pushed by `ThreadCall`. When this frame
29///   exhausts, the thread is done — inherited frames below it are never
30///   unwound into during normal execution. `->->` (`TunnelReturn`) strips
31///   Thread frames to find the enclosing Tunnel.
32/// - **External**: pushed by `CallExternal`. Holds popped arguments in
33///   `temps` and the external function's [`DefinitionId`] in
34///   `external_fn_id`. The orchestration layer resolves it (binding or
35///   fallback) before the VM resumes.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub(crate) enum CallFrameType {
38    Root,
39    Function,
40    Tunnel,
41    Thread,
42    External,
43    /// Boundary frame pushed by an engine→ink call
44    /// ([`FlowInstance::begin_function_eval`]). Behaves like `Function`
45    /// for output trimming and implicit-return purposes, but marks where
46    /// a from-game evaluation began so the eval driver knows when the
47    /// function has returned. Mirrors C#'s
48    /// `PushPopType.FunctionEvaluationFromGame`.
49    FunctionEvalFromGame,
50}
51
52/// Classify *why* execution ran out of content, from the exhausted frame's
53/// type and whether the call stack could pop at all at that instant.
54/// Mirrors C#'s `Story.Continue()` selection (`Story.cs`): a tunnel or
55/// function frame gets its own message; a stack that can't pop at all (only
56/// the root frame remains) is the plain case; anything else (a `Thread`
57/// boundary, an in-progress `FunctionEvalFromGame` frame) is the "unknown
58/// reason" backstop — a call-stack shape well-formed compiler output should
59/// never produce. Called from [`crate::vm::handle_frame_exhaustion`] at the
60/// exact moment a frame's content is discovered exhausted — the same
61/// instant C# reads `callStack.CanPop` — before this runtime's own
62/// exhaustion recovery (which, unlike C#, always pops the exhausted frame)
63/// can change the stack's shape out from under a later read.
64pub(crate) fn classify_ran_out_of_content(
65    frame_type: CallFrameType,
66    can_pop: bool,
67) -> RanOutOfContentCause {
68    if can_pop && frame_type == CallFrameType::Tunnel {
69        RanOutOfContentCause::Tunnel
70    } else if can_pop && frame_type == CallFrameType::Function {
71        RanOutOfContentCause::Function
72    } else if can_pop {
73        RanOutOfContentCause::Unknown
74    } else {
75        RanOutOfContentCause::Plain
76    }
77}
78
79#[derive(Debug, Clone)]
80pub(crate) struct CallFrame {
81    pub return_address: Option<ContainerPosition>,
82    pub temps: Vec<Value>,
83    pub container_stack: Vec<ContainerPosition>,
84    pub frame_type: CallFrameType,
85    /// For `External` frames: the `DefinitionId` of the external function,
86    /// used to look up the fallback container if no binding is registered.
87    pub external_fn_id: Option<DefinitionId>,
88    /// For `Function` frames: the length of the active output target at
89    /// call time.  On return, trailing whitespace is trimmed back to this
90    /// point — matching the C# runtime's `TrimWhitespaceFromFunctionEnd`.
91    pub function_output_start: Option<usize>,
92}
93
94/// Two-part call stack: shared read-only prefix + owned mutable frames.
95///
96/// `fork_thread` snapshots the parent's frames into a cached `Arc<[CallFrame]>`
97/// (one clone, amortized across all children). Children get `Arc::clone` — O(1).
98/// The parent keeps its `own` vec unchanged and continues mutating freely.
99#[derive(Debug, Clone)]
100pub(crate) struct CallStack {
101    /// Shared read-only prefix inherited from the parent thread.
102    inherited: Option<Arc<[CallFrame]>>,
103    /// Frames owned by this thread (above the fork point).
104    own: Vec<CallFrame>,
105    /// Cached snapshot so multiple forks from the same parent share one allocation.
106    cached_snapshot: Option<Arc<[CallFrame]>>,
107    /// Count of materializations (flattening inherited prefix into own).
108    pub(crate) materialization_count: u64,
109}
110
111impl CallStack {
112    pub fn new(frame: CallFrame) -> Self {
113        Self {
114            inherited: None,
115            own: vec![frame],
116            cached_snapshot: None,
117            materialization_count: 0,
118        }
119    }
120
121    pub fn push(&mut self, frame: CallFrame) {
122        self.cached_snapshot = None;
123        self.own.push(frame);
124    }
125
126    pub fn pop(&mut self) -> Option<CallFrame> {
127        self.cached_snapshot = None;
128        if let Some(f) = self.own.pop() {
129            return Some(f);
130        }
131        self.materialize();
132        self.own.pop()
133    }
134
135    pub fn last(&self) -> Option<&CallFrame> {
136        self.own
137            .last()
138            .or_else(|| self.inherited.as_ref().and_then(|h| h.last()))
139    }
140
141    pub fn last_mut(&mut self) -> Option<&mut CallFrame> {
142        if !self.own.is_empty() {
143            return self.own.last_mut();
144        }
145        self.materialize();
146        self.own.last_mut()
147    }
148
149    pub fn len(&self) -> usize {
150        self.inherited.as_ref().map_or(0, |h| h.len()) + self.own.len()
151    }
152
153    pub fn is_empty(&self) -> bool {
154        self.own.is_empty() && self.inherited.as_ref().is_none_or(|h| h.is_empty())
155    }
156
157    /// Get a frame by absolute index (0 = bottom of stack).
158    pub fn get(&self, index: usize) -> Option<&CallFrame> {
159        let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
160        if index < inherited_len {
161            self.inherited.as_ref().and_then(|h| h.get(index))
162        } else {
163            self.own.get(index - inherited_len)
164        }
165    }
166
167    /// Get a mutable reference to a frame by absolute index.
168    /// Materializes the inherited prefix if the target is in it.
169    pub fn get_mut(&mut self, index: usize) -> Option<&mut CallFrame> {
170        let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
171        if index < inherited_len {
172            self.materialize();
173            self.own.get_mut(index)
174        } else {
175            self.own.get_mut(index - inherited_len)
176        }
177    }
178
179    /// Build an `Arc<[CallFrame]>` snapshot of the full stack (inherited + own).
180    /// The result is cached so multiple forks from the same parent share one
181    /// allocation. Returns `(snapshot, cache_hit)`.
182    pub fn snapshot(&mut self) -> (Arc<[CallFrame]>, bool) {
183        if let Some(ref cached) = self.cached_snapshot {
184            return (Arc::clone(cached), true);
185        }
186        let rc = match &self.inherited {
187            None => Arc::from(self.own.as_slice()),
188            Some(prefix) if self.own.is_empty() => Arc::clone(prefix),
189            Some(prefix) => {
190                let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
191                combined.extend_from_slice(prefix);
192                combined.extend_from_slice(&self.own);
193                Arc::from(combined)
194            }
195        };
196        self.cached_snapshot = Some(Arc::clone(&rc));
197        (rc, false)
198    }
199
200    /// Flatten inherited prefix into `own`. Returns `true` if work was done.
201    fn materialize(&mut self) -> bool {
202        self.cached_snapshot = None;
203        if let Some(prefix) = self.inherited.take() {
204            let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
205            combined.extend_from_slice(&prefix);
206            combined.append(&mut self.own);
207            self.own = combined;
208            self.materialization_count += 1;
209            true
210        } else {
211            false
212        }
213    }
214}
215
216/// A single execution thread with its own call stack.
217#[derive(Debug, Clone)]
218pub(crate) struct Thread {
219    pub call_stack: CallStack,
220}
221
222/// How the choice display text is stored internally.
223#[derive(Debug, Clone)]
224pub(crate) enum ChoiceDisplay {
225    /// Eagerly resolved text (legacy path, converter, or non-fragment codegen).
226    Text(String),
227    /// Index into the output buffer's fragment store — resolved on demand.
228    Fragment(u32),
229}
230
231#[derive(Debug, Clone)]
232pub(crate) struct PendingChoice {
233    pub display: ChoiceDisplay,
234    pub target_id: DefinitionId,
235    pub target_idx: u32,
236    pub target_offset: usize,
237    pub flags: ChoiceFlags,
238    #[expect(
239        dead_code,
240        reason = "needs research — likely needed for structured output / voice acting"
241    )]
242    pub original_index: usize,
243    /// Tags collected during choice evaluation.
244    pub tags: Vec<String>,
245    /// Snapshot of the current thread at choice creation time, so that
246    /// selecting this choice can restore the execution context
247    /// (including temp variables from enclosing tunnels/functions).
248    pub thread_fork: Thread,
249}
250
251/// The dev/prod execution mode (NS-A4, `docs/stdlib-spec.md` §4b, ruled
252/// 2026-07-18): the knob that decides WHERE execution stops on an unordered
253/// comparand — never WHAT values are fabricated.
254///
255/// The split is **fenced to placement**: it exists only where the prod
256/// behavior is defined, total, and fabricates no data. Ordering contexts
257/// qualify (`sort`/`sorted`/`min`/`max`; A7 adds `heap_push`): every element
258/// is preserved, the order is deterministic, saves/replay are safe.
259/// Fabrication never qualifies — `int("potato")`, OOB indexing stay
260/// always-fault in both modes. Effect rows are mode-independent (the checker
261/// doesn't know modes exist).
262///
263/// - [`Dev`](Self::Dev) (the default — the Rust dev-profile analogy, like
264///   debug-build overflow checks): a float NaN comparand in an ordering
265///   context is a turn-terminating [`RuntimeError::UnorderedComparand`]
266///   fault, surfacing the upstream bug at its first ordering consumption.
267/// - [`Prod`](Self::Prod): the pinned non-fabricating total order applies —
268///   ordinary IEEE order with `-0 == +0` as a tie, NaN greater than
269///   everything, NaN-vs-NaN ties (deliberately NOT IEEE `totalOrder`, whose
270///   `-0 < +0` would split ordering from `==` on clean data). Execution
271///   keeps moving.
272///
273/// On NaN-free data the modes agree exactly and cohere with `<`/`==`.
274///
275/// The knob's *home* is project config (`brink.toml` profile) with a
276/// host-API override (ruled 2026-07-19; tooling wires the config side).
277/// This runtime mechanism is the host-API leg: set it via
278/// [`Story::set_exec_mode`] / [`FlowInstance::set_exec_mode`]. The mode is
279/// a host/build knob, not story state — it is never embedded in `.inkb`
280/// (mirroring `dialect`/`types`) and never persisted in saves.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub enum ExecMode {
283    /// Fault on unordered comparands (NaN) in ordering contexts.
284    #[default]
285    Dev,
286    /// Keep moving: place NaN by the pinned non-fabricating total order.
287    Prod,
288}
289
290/// Per-flow execution context. Owns threads, eval stack, output, choices.
291#[derive(Debug, Clone)]
292#[expect(
293    clippy::struct_excessive_bools,
294    reason = "VM flags are inherently boolean"
295)]
296pub(crate) struct Flow {
297    pub threads: Vec<Thread>,
298    pub value_stack: Vec<Value>,
299    pub output: OutputBuffer,
300    pub pending_choices: Vec<PendingChoice>,
301    pub current_tags: Vec<String>,
302    pub in_tag: bool,
303    pub skipping_choice: bool,
304    /// Set to `true` when a `Done` opcode fires (explicit `-> DONE`).
305    /// Cleared at the start of each `continue_single` call.
306    pub did_safe_exit: bool,
307    /// Set to `true` when a `Yield` opcode falls through with no
308    /// pending choices — the story passed through an empty choice set.
309    /// Cleared at the start of each `continue_single` call.
310    pub did_unsafe_yield: bool,
311    /// The call-stack-derived cause captured the moment execution hit the
312    /// content-exhaustion boundary ([`crate::vm::handle_frame_exhaustion`])
313    /// that produced the terminal `Done` — mirrors C#'s inline
314    /// `CanPop(Tunnel)`/`CanPop(Function)`/`!canPop` selection (`Story.cs`)
315    /// at the instant it happens, before this runtime's own frame unwinding
316    /// (which, unlike C#, always pops the exhausted frame — see the type's
317    /// own docs) can erase the evidence. Read by
318    /// [`FlowInstance::advance_with_limit`](crate::story::FlowInstance::advance_with_limit)'s
319    /// deferred "ran out of content" fault one `continue_single` call
320    /// later. Written *only* on the exhaustion paths that themselves return
321    /// `Done` — an exhaustion that instead resumes execution (a completed
322    /// thread with a parent to fall back to, a popped frame with content
323    /// still below it) never touches this field, so a transient exhaustion
324    /// elsewhere on the same flow (e.g. a `Story::call_function` boundary
325    /// evaluating a function that calls a void helper) can't clobber a
326    /// cause an earlier, still-pending exhaustion already recorded. Not
327    /// cleared between cycles like the two flags above it — it is
328    /// meaningless unless `did_safe_exit` is `false` at the same `Done`,
329    /// which is the only condition under which it is ever read.
330    pub ran_out_of_content_cause: RanOutOfContentCause,
331    /// The dev/prod execution mode (NS-A4, [`ExecMode`]). A host/build
332    /// knob, not story state — never persisted; defaults to
333    /// [`ExecMode::Dev`].
334    pub exec_mode: ExecMode,
335    /// In-flight nested **pure-callback** evaluation state — see
336    /// [`PureCallbackState`].
337    pub pure_callback: PureCallbackState,
338    /// The [`super::types::BlockId`] stamped onto the next `Step::Line`
339    /// produced by this flow — counts uninterrupted runs of adjacent
340    /// content (`docs/prose-dialect-spec.md` §3.7/§8d.2). Bumped whenever a
341    /// new run begins: after a choice is selected, after resuming from
342    /// `Done`, and on a host-directed jump (`choose_path_string`, which is
343    /// itself specified as force-completing the current flow like `->
344    /// DONE`).
345    ///
346    /// ⚠ **Persistence is boundary-dependent, not uniformly "never" (the
347    /// 2026-08-05 ruling on #2108, `docs/decision-log.md`).** This field's
348    /// doc previously said outright that it is *never* persisted — that
349    /// claim was true only for the *ordinary* save (`Story::save_state`/
350    /// `load_state`, `brink_runtime::save`'s free functions): that save
351    /// captures game state only, the host re-enters at a known knot on
352    /// load, and a fresh `0` there is genuinely harmless because nothing
353    /// ever compares a block id *across* that boundary — a brand-new run
354    /// starting fresh is exactly what re-entering a knot means. **That part
355    /// of the old claim still holds and this field is still not part of
356    /// `SaveState` itself.**
357    ///
358    /// It stopped being true unconditionally once element attachment
359    /// (`@[convention(..., attach = X)]`, #2108) could leave a run *open*
360    /// across a suspension: a block is not just lines — executable
361    /// statements can interleave with an attach-scoped dialogue run, and
362    /// `Step::Suspended` is deliberately not one of the run-terminators
363    /// above (an `await` can fire mid-run). A flow parked there is resumed
364    /// from its exact execution position via `brink_format::SuspendedFlow`
365    /// (the `FlowFrame`, `docs/flow-suspension-spec.md` §2/§9) — genuinely
366    /// continuing the SAME run, not re-entering a knot from the top — so a
367    /// numbering restart at `0` there would silently collide with (or just
368    /// diverge from) the pre-park sequence, and the run's element data
369    /// (`crate::output::OutputBuffer`'s carried-forward attachment state)
370    /// would reset to empty, dropping the attributed speaker. For that
371    /// boundary only, this value **is** persisted — see
372    /// `SuspendedFlow::next_block_id`/`SuspendedFlow::pending_element`'s own
373    /// docs — even though the ordinary game-state save still never touches
374    /// it.
375    pub next_block_id: u64,
376    /// A terminal ([`super::types::Step`] variant with no line payload)
377    /// computed but not yet delivered, because its trailing content needed
378    /// to go out first as an ordinary `Step::Line` (terminals carry no
379    /// text — `docs/prose-dialect-spec.md` §7). Consumed and returned bare
380    /// on the very next `advance` call, with no VM stepping — see
381    /// [`PendingTerminal`] for the invalidation invariant this type
382    /// enforces.
383    pub pending_terminal: PendingTerminal,
384}
385
386/// A terminal computed for the current run but held back because its
387/// trailing content had to flush first as its own `Step::Line` (terminals
388/// carry no text of their own — `docs/prose-dialect-spec.md` §7). Stamped
389/// with the [`Flow::next_block_id`] value current at the moment it was
390/// computed.
391///
392/// **Invariant this type exists to enforce** (the bug found in #1684's
393/// review, filed as #2104): a stashed terminal must never be handed back
394/// after a host-directed jump or choice has moved execution somewhere else
395/// in the meantime — `choose`/`choose_path_string`/`choose_path_string_with_args`
396/// all force-complete the current run and begin a fresh one (bumping
397/// `next_block_id`, per that field's own doc comment: "Bumped whenever a
398/// new run begins: after a choice is selected, after resuming from `Done`,
399/// and on a host-directed jump"). [`take_if_current`](Self::take_if_current)
400/// compares the stash's stamp against the block id current *at read time*
401/// and silently discards a stale stash rather than returning it — so the
402/// invariant holds **by construction**: any call site that begins a new run
403/// only has to keep bumping `next_block_id` for its own reasons (block-id
404/// correctness for `Step::Line`, already required whether or not this type
405/// existed), and pending-terminal invalidation falls out for free. No call
406/// site needs its own `= None` clear, so a future host-directed mutation
407/// (a rewind, a fast-forward) that begins a new run cannot reintroduce this
408/// bug by forgetting one.
409///
410/// `Story::load_state`/the free `load_state` function are **not** part of
411/// this invariant's surface: they reconcile only game state (globals,
412/// visit/turn counts) into a `ContextAccess`, never touching `Flow` or its
413/// `next_block_id`/`pending_terminal` at all — so they cannot leave a stale
414/// stash behind, and need no clear of their own. See
415/// `docs/runtime-spec.md`'s pending-terminal section.
416#[derive(Debug, Clone, Default)]
417pub(crate) struct PendingTerminal(Option<(u64, super::types::Step)>);
418
419impl PendingTerminal {
420    /// Stash `terminal`, stamped with the run (`next_block_id`) it was
421    /// computed under.
422    pub(crate) fn stash(&mut self, block_id: u64, terminal: super::types::Step) {
423        self.0 = Some((block_id, terminal));
424    }
425
426    /// Take the stashed terminal iff its stamp matches `current_block_id` —
427    /// i.e. no new run has begun since it was stashed. Always empties the
428    /// slot (fresh or stale), so a stale stash can never be read twice.
429    pub(crate) fn take_if_current(&mut self, current_block_id: u64) -> Option<super::types::Step> {
430        self.0
431            .take()
432            .and_then(|(stamp, terminal)| (stamp == current_block_id).then_some(terminal))
433    }
434}
435
436/// Transient bookkeeping for in-flight nested callback evaluations: a
437/// `sort_by`/`sorted_by` comparator (NS-A4), a pure fn-value verb's callback
438/// (`map`/`filter`/`fold`/`filter_map` — `docs/stdlib-spec.md` §4, issue
439/// #1679), or an effectful fn-value verb's callback (`each`/`map_each`,
440/// issue #1679 slice 2). All three re-enter [`crate::vm::step`] from inside
441/// a single opcode, so they share one counter — `effectful` is what splits
442/// the two runtime contracts without splitting the bookkeeping.
443///
444/// Never persisted (a callback always completes within the opcode that
445/// started it). The depth guards Rust stack recursion when a callback itself
446/// runs a callback verb, regardless of which contract; the verb name is
447/// what the dev-mode world-write guard reports.
448#[derive(Debug, Clone, Copy, Default)]
449pub(crate) struct PureCallbackState {
450    /// Number of nested callback evaluations currently on the Rust stack
451    /// (pure and effectful alike).
452    pub depth: u16,
453    /// The source spelling of the innermost verb whose callback is running
454    /// (`"sort_by"`, `"map"`, `"each"`, …). Only meaningful while
455    /// `depth > 0`; the default `""` is never read.
456    pub verb: &'static str,
457    /// Whether the innermost running callback is the **effectful** contract
458    /// (`each`/`map_each`): output reaches the transcript instead of being
459    /// captured, and [`crate::vm`]'s dev-mode world-write guard is disarmed
460    /// for it. `false` for the pure quartet and for `sort_by`/`sorted_by`.
461    /// Only meaningful while `depth > 0`.
462    pub effectful: bool,
463}
464
465impl Flow {
466    /// Returns a reference to the current (topmost) thread.
467    ///
468    /// # Panics
469    ///
470    /// Panics if the thread stack is empty. This is a programming error —
471    /// flows are always constructed with at least one thread.
472    #[expect(clippy::expect_used)]
473    pub fn current_thread(&self) -> &Thread {
474        self.threads
475            .last()
476            .expect("flow must always have at least one thread")
477    }
478
479    /// Returns a mutable reference to the current (topmost) thread.
480    ///
481    /// # Panics
482    ///
483    /// Panics if the thread stack is empty. This is a programming error —
484    /// flows are always constructed with at least one thread.
485    #[expect(clippy::expect_used)]
486    pub fn current_thread_mut(&mut self) -> &mut Thread {
487        self.threads
488            .last_mut()
489            .expect("flow must always have at least one thread")
490    }
491
492    pub fn can_pop_thread(&self) -> bool {
493        self.threads.len() > 1
494    }
495
496    /// Returns `true` if a `FunctionEvalFromGame` boundary frame is present
497    /// in the current thread's call stack — i.e. an engine→ink function
498    /// evaluation is still in progress. Functions don't fork threads, so
499    /// the current thread is where the boundary lives. The eval driver
500    /// uses this to detect when the function has returned (boundary popped).
501    pub fn has_eval_boundary(&self) -> bool {
502        let cs = &self.current_thread().call_stack;
503        (0..cs.len())
504            .filter_map(|i| cs.get(i))
505            .any(|f| f.frame_type == CallFrameType::FunctionEvalFromGame)
506    }
507
508    pub fn pop_thread(&mut self) {
509        self.threads.pop();
510    }
511
512    /// Fork a new thread from the current one. Returns `(thread, snapshot_cache_hit)`.
513    pub fn fork_thread(&mut self) -> (Thread, bool) {
514        let (shared, cache_hit) = self.current_thread_mut().call_stack.snapshot();
515        (
516            Thread {
517                call_stack: CallStack {
518                    inherited: Some(shared),
519                    own: Vec::new(),
520                    cached_snapshot: None,
521                    materialization_count: 0,
522                },
523            },
524            cache_hit,
525        )
526    }
527
528    /// Drain materialization counts from all thread call stacks.
529    pub fn drain_materializations(&mut self) -> u64 {
530        let mut total = 0;
531        for thread in &mut self.threads {
532            total += thread.call_stack.materialization_count;
533            thread.call_stack.materialization_count = 0;
534        }
535        total
536    }
537
538    /// Read the arguments from the top External frame.
539    pub fn external_args(&self) -> &[Value] {
540        let frame = self.current_thread().call_stack.last();
541        match frame {
542            Some(f) if f.frame_type == CallFrameType::External => &f.temps,
543            _ => &[],
544        }
545    }
546
547    /// Read the external function's `DefinitionId` from the top External frame.
548    pub fn external_fn_id(&self) -> Option<DefinitionId> {
549        let frame = self.current_thread().call_stack.last()?;
550        if frame.frame_type == CallFrameType::External {
551            frame.external_fn_id
552        } else {
553            None
554        }
555    }
556
557    /// Resolve an external call: pop the External frame and push the
558    /// return value onto the value stack.
559    pub fn resolve_external(&mut self, value: Value) {
560        let thread = self.current_thread_mut();
561        if let Some(frame) = thread.call_stack.last()
562            && frame.frame_type == CallFrameType::External
563        {
564            let ret_addr = frame.return_address;
565            thread.call_stack.pop();
566            self.value_stack.push(value);
567            // Restore position from return address (if any).
568            if let Some(pos) = ret_addr
569                && let Some(f) = self.current_thread_mut().call_stack.last_mut()
570                && let Some(top) = f.container_stack.last_mut()
571            {
572                *top = pos;
573            }
574        }
575    }
576
577    /// Replace the External frame with a Function frame pointing at the
578    /// fallback container. Args are pushed back onto the value stack so
579    /// the fallback body's `temp=` opcodes can pop them.
580    pub fn invoke_fallback(&mut self, container_idx: u32) {
581        let output_start = self.output.target_len();
582        let thread = self.current_thread_mut();
583        if let Some(frame) = thread.call_stack.last_mut()
584            && frame.frame_type == CallFrameType::External
585        {
586            let args = core::mem::take(&mut frame.temps);
587            frame.frame_type = CallFrameType::Function;
588            frame.container_stack = vec![ContainerPosition {
589                container_idx,
590                offset: 0,
591            }];
592            frame.external_fn_id = None;
593            frame.function_output_start = Some(output_start);
594            // Push args back onto the value stack — the fallback body
595            // starts with `temp=` instructions that pop them.
596            self.value_stack.extend(args);
597        }
598    }
599
600    /// Pop a value from the value stack.
601    pub fn pop_value(&mut self) -> Result<Value, RuntimeError> {
602        self.value_stack.pop().ok_or(RuntimeError::StackUnderflow)
603    }
604
605    /// Peek at the top value without popping.
606    pub fn peek_value(&self) -> Result<&Value, RuntimeError> {
607        self.value_stack.last().ok_or(RuntimeError::StackUnderflow)
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::story::Step;
615
616    // ── PendingTerminal ───────────────────────────────────────────────────
617    //
618    // Unit-level coverage for the invalidation invariant itself (see
619    // `PendingTerminal`'s own doc comment): a stash is only ever handed
620    // back if the caller's current block id still matches the one it was
621    // stamped with, and a read — fresh or stale — always empties the slot.
622    // The `FlowInstance`-level regression tests for the actual bug this
623    // guards against (a stashed terminal replaying after a host jump or a
624    // choice) live in `brink-test-harness/tests/jump_to_path.rs`
625    // (`jump_right_after_content_line_does_not_replay_stale_terminal`,
626    // `choose_right_after_content_line_does_not_replay_stale_terminal`).
627
628    /// A stash read back under the SAME block id it was stamped with (the
629    /// ordinary case: content flushes, then the terminal is delivered on
630    /// the very next call, with no run boundary in between) is returned.
631    #[test]
632    fn take_if_current_returns_a_fresh_stash() {
633        let mut pending = PendingTerminal::default();
634        pending.stash(3, Step::Done);
635        assert_eq!(pending.take_if_current(3), Some(Step::Done));
636    }
637
638    /// A stash read back under a LATER block id than the one it was
639    /// stamped with — exactly what happens after a host-directed jump or
640    /// choice, both of which bump `next_block_id` before the flow is ever
641    /// asked to advance again — is discarded rather than replayed. This is
642    /// the mechanism that makes the invariant hold **without** a jump/choice
643    /// call site needing its own explicit `= None` clear.
644    #[test]
645    fn take_if_current_discards_a_stash_stamped_for_an_earlier_block() {
646        let mut pending = PendingTerminal::default();
647        pending.stash(3, Step::Done);
648        assert_eq!(
649            pending.take_if_current(4),
650            None,
651            "a stash stamped for block 3 must not surface once the current \
652             block has moved to 4"
653        );
654    }
655
656    /// Reading the slot always empties it — a stale stash discarded by one
657    /// read can't somehow surface on a later read even if that later read
658    /// happens to use the original stamp again.
659    #[test]
660    fn take_if_current_always_empties_the_slot_even_when_stale() {
661        let mut pending = PendingTerminal::default();
662        pending.stash(3, Step::Done);
663        assert_eq!(pending.take_if_current(4), None, "first (stale) read");
664        assert_eq!(
665            pending.take_if_current(3),
666            None,
667            "the slot was already emptied by the stale read above — it must \
668             not resurrect the old value just because the stamp is asked \
669             for again"
670        );
671    }
672
673    /// An empty slot never produces a terminal, regardless of which block
674    /// id is asked for.
675    #[test]
676    fn take_if_current_on_an_empty_slot_is_always_none() {
677        let mut pending = PendingTerminal::default();
678        assert_eq!(pending.take_if_current(0), None);
679    }
680
681    /// A tunnel frame that can still pop classifies as `Tunnel` — mirrors
682    /// C#'s `callStack.CanPop(PushPopType.Tunnel)` arm.
683    #[test]
684    fn classify_tunnel_with_can_pop_is_tunnel() {
685        assert_eq!(
686            classify_ran_out_of_content(CallFrameType::Tunnel, true),
687            RanOutOfContentCause::Tunnel
688        );
689    }
690
691    /// A function frame that can still pop classifies as `Function` —
692    /// mirrors C#'s `callStack.CanPop(PushPopType.Function)` arm.
693    #[test]
694    fn classify_function_with_can_pop_is_function() {
695        assert_eq!(
696            classify_ran_out_of_content(CallFrameType::Function, true),
697            RanOutOfContentCause::Function
698        );
699    }
700
701    /// Any other frame type that can still pop (a `Thread` boundary, a
702    /// `FunctionEvalFromGame` boundary, even `Root`/`External`) falls to
703    /// the "unknown reason" backstop — mirrors C#'s final `else` arm.
704    #[test]
705    fn classify_other_frame_types_with_can_pop_is_unknown() {
706        for frame_type in [
707            CallFrameType::Root,
708            CallFrameType::Thread,
709            CallFrameType::External,
710            CallFrameType::FunctionEvalFromGame,
711        ] {
712            assert_eq!(
713                classify_ran_out_of_content(frame_type, true),
714                RanOutOfContentCause::Unknown,
715                "frame type {frame_type:?} with can_pop=true should classify as Unknown"
716            );
717        }
718    }
719
720    /// A call stack that can't pop at all — regardless of the exhausted
721    /// frame's type — is the plain "story fell off the end" case. Mirrors
722    /// C#'s `!callStack.canPop` arm, which is checked before frame-type
723    /// distinctions are even considered.
724    #[test]
725    fn classify_cannot_pop_is_always_plain() {
726        for frame_type in [
727            CallFrameType::Root,
728            CallFrameType::Function,
729            CallFrameType::Tunnel,
730            CallFrameType::Thread,
731            CallFrameType::External,
732            CallFrameType::FunctionEvalFromGame,
733        ] {
734            assert_eq!(
735                classify_ran_out_of_content(frame_type, false),
736                RanOutOfContentCause::Plain,
737                "frame type {frame_type:?} with can_pop=false should classify as Plain"
738            );
739        }
740    }
741}