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}