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::vec::Vec;
6
7use brink_format::{ChoiceFlags, DefinitionId, Value};
8
9use core::ops::Range;
10
11use crate::error::{RanOutOfContentCause, RuntimeError};
12use crate::output::{OutputBuffer, OutputMark};
13
14// ── Internal types ──────────────────────────────────────────────────────────
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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/// - **External**: pushed by `CallExternal`. Holds popped arguments in
29///   `temps` and the external function's [`DefinitionId`] in
30///   `external_fn_id`. The orchestration layer resolves it (binding or
31///   fallback) before the VM resumes.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(crate) enum CallFrameType {
34    Root,
35    Function,
36    Tunnel,
37    External,
38    /// Boundary frame pushed by an engine→ink call
39    /// ([`FlowInstance::begin_function_eval`]). Behaves like `Function`
40    /// for output trimming and implicit-return purposes, but marks where
41    /// a from-game evaluation began so the eval driver knows when the
42    /// function has returned. Mirrors C#'s
43    /// `PushPopType.FunctionEvaluationFromGame`.
44    FunctionEvalFromGame,
45}
46
47/// Classify *why* execution ran out of content, from the exhausted frame's
48/// type and whether the call stack could pop at all at that instant.
49/// Mirrors C#'s `Story.Continue()` selection (`Story.cs`): a tunnel or
50/// function frame gets its own message; a stack that can't pop at all (only
51/// the root frame remains) is the plain case; anything else (an in-progress
52/// `FunctionEvalFromGame` frame, say) is the "unknown reason" backstop — a
53/// call-stack shape well-formed compiler output should never produce. Called from [`crate::vm::handle_frame_exhaustion`] at the
54/// exact moment a frame's content is discovered exhausted — the same
55/// instant C# reads `callStack.CanPop` — before this runtime's own
56/// exhaustion recovery (which, unlike C#, always pops the exhausted frame)
57/// can change the stack's shape out from under a later read.
58pub(crate) fn classify_ran_out_of_content(
59    frame_type: CallFrameType,
60    can_pop: bool,
61) -> RanOutOfContentCause {
62    if can_pop && frame_type == CallFrameType::Tunnel {
63        RanOutOfContentCause::Tunnel
64    } else if can_pop && frame_type == CallFrameType::Function {
65        RanOutOfContentCause::Function
66    } else if can_pop {
67        RanOutOfContentCause::Unknown
68    } else {
69        RanOutOfContentCause::Plain
70    }
71}
72
73/// One activation on a [`CallStack`]: the frame *header*. Its temp slots and
74/// container positions live in the stack's shared, contiguous storage,
75/// addressed by the two base offsets here (see [`CallStack`]).
76#[derive(Debug, Clone, Copy)]
77pub(crate) struct CallFrame {
78    pub return_address: Option<ContainerPosition>,
79    pub frame_type: CallFrameType,
80    /// For `External` frames: the `DefinitionId` of the external function,
81    /// used to look up the fallback container if no binding is registered.
82    pub external_fn_id: Option<DefinitionId>,
83    /// For `Function` frames: where the active output target stood at call
84    /// time ([`OutputMark`]). On return, trailing whitespace is trimmed
85    /// back to this point — matching the C# runtime's
86    /// `TrimWhitespaceFromFunctionEnd` — and while the function has
87    /// produced no content past it, a newline it emits is dropped
88    /// (`functionStartInOutputStream`, issue #3519).
89    pub function_output_start: Option<OutputMark>,
90    /// Start of this frame's temp slots in [`CallStack::temps`] (and, in
91    /// parallel, [`CallStack::temps_written`]). Set by `CallStack::push`;
92    /// the segment ends where the next frame's begins, or at the end of the
93    /// storage for the top frame.
94    temps_base: usize,
95    /// Start of this frame's container positions in
96    /// [`CallStack::containers`]; same segment rule as `temps_base`.
97    containers_base: usize,
98}
99
100impl CallFrame {
101    /// A frame header. The storage bases are assigned when the frame is
102    /// pushed, so a header is inert until then.
103    pub fn new(
104        frame_type: CallFrameType,
105        return_address: Option<ContainerPosition>,
106        function_output_start: Option<OutputMark>,
107    ) -> Self {
108        Self {
109            return_address,
110            frame_type,
111            external_fn_id: None,
112            function_output_start,
113            temps_base: 0,
114            containers_base: 0,
115        }
116    }
117
118    /// An `External` frame header, parked on `fn_id` until the host
119    /// resolves the call.
120    pub fn external(fn_id: DefinitionId, return_address: Option<ContainerPosition>) -> Self {
121        Self {
122            external_fn_id: Some(fn_id),
123            ..Self::new(CallFrameType::External, return_address, None)
124        }
125    }
126}
127
128/// Initial reservation for a thread's temp-slot storage, in slots.
129const TEMPS_RESERVE: usize = 64;
130/// Initial reservation for a thread's container-position storage.
131const CONTAINERS_RESERVE: usize = 16;
132/// Initial reservation for a thread's frame headers.
133const FRAMES_RESERVE: usize = 8;
134
135/// A thread's call stack, laid out as one contiguous stack per kind of
136/// per-frame data.
137///
138/// Every frame's temp slots sit end to end in `temps` (with `temps_written`
139/// in parallel), and every frame's container positions sit end to end in
140/// `containers`; a [`CallFrame`] records only where its segments begin.
141/// Pushing a frame records the current lengths as its bases and allocates
142/// nothing; popping truncates back to them. Each thread reserves its
143/// storage once at creation, so a function call at steady state is three
144/// integer stores and no allocator traffic at all.
145///
146/// This replaced a `Vec<CallFrame>` whose every frame owned three
147/// separately heap-allocated `Vec`s — a `container_stack` of one element,
148/// `temps`, and `temps_written` — which made every function call three
149/// `malloc`s and three `free`s: 83% of all heap blocks on `crucible-8`
150/// and 13.7% of its instructions in the allocator (DHAT and callgrind,
151/// measured after #3569). The per-step instruction-pointer advance also
152/// loses a pointer chase: the top container is the last element of one
153/// `Vec` instead of the last element of a `Vec` inside the last element of
154/// another.
155///
156/// Frame segments are sized lazily, as temps are declared by slot index
157/// (the format carries no per-container slot count). The top frame grows
158/// by pushing; a lower frame — reachable only through a `ref` parameter's
159/// [`Value::TempPointer`] — grows by inserting at its segment's end and
160/// shifting the bases of every frame above it. That path is essentially
161/// never taken (a `ref` names a slot its owner has already declared) and
162/// is O(stack) when it is; it exists so the shared layout is exact in every
163/// case, not just the common one.
164///
165/// A thread fork clones this whole structure: four `Vec` clones per fork,
166/// where the old layout paid one per frame per `Vec`.
167#[derive(Debug, Clone)]
168pub(crate) struct CallStack {
169    frames: Vec<CallFrame>,
170    /// Every frame's temp slots, end to end.
171    temps: Vec<Value>,
172    /// Parallel to `temps`: `temps_written[i]` is `true` once slot `i` has
173    /// been the target of a real write (`DeclareTemp`, `SetTemp`, the
174    /// `TempPointer` write-through target, or the `as`-binding store) —
175    /// never set merely because a segment grew to cover the index.
176    ///
177    /// Issue #3354's `GetTemp` fallback (see `vm.rs`) needs this because
178    /// `Value::Null` is not a reliable "never written" marker: it is also
179    /// the padding a segment grows with to reach a new highest index, AND
180    /// it is the value a real, completed `DeclareTemp` legitimately stores
181    /// when its initializer itself evaluates to `Null` (a void-returning
182    /// function assigned into a temp). Keying the fallback on the *value*
183    /// conflated those two cases; keying on this bitmap does not.
184    temps_written: Vec<bool>,
185    /// Every frame's container positions, end to end.
186    containers: Vec<ContainerPosition>,
187}
188
189impl CallStack {
190    /// A stack holding `root` as its only frame, executing at `entry` (or
191    /// nowhere, for a reset stack that is done).
192    pub fn new(root: CallFrame, entry: Option<ContainerPosition>) -> Self {
193        let mut stack = Self {
194            frames: Vec::with_capacity(FRAMES_RESERVE),
195            temps: Vec::with_capacity(TEMPS_RESERVE),
196            temps_written: Vec::with_capacity(TEMPS_RESERVE),
197            containers: Vec::with_capacity(CONTAINERS_RESERVE),
198        };
199        stack.push(root, entry);
200        stack
201    }
202
203    // ── Frames ─────────────────────────────────────────────────────────
204
205    /// Push `frame`, executing at `entry` if given. Its segments begin at
206    /// the current end of the shared storage.
207    pub fn push(&mut self, mut frame: CallFrame, entry: Option<ContainerPosition>) {
208        frame.temps_base = self.temps.len();
209        frame.containers_base = self.containers.len();
210        self.frames.push(frame);
211        if let Some(pos) = entry {
212            self.containers.push(pos);
213        }
214    }
215
216    /// Push an `External` frame holding `args` as its temp slots, every one
217    /// of them written by construction (they are supplied values, not
218    /// padding). It executes nowhere until resolved.
219    pub fn push_with_args(&mut self, frame: CallFrame, args: Vec<Value>) {
220        self.push(frame, None);
221        self.temps_written
222            .resize(self.temps.len() + args.len(), true);
223        self.temps.extend(args);
224    }
225
226    /// Pop the top frame, releasing its segments.
227    pub fn pop(&mut self) -> Option<CallFrame> {
228        let frame = self.frames.pop()?;
229        self.temps.truncate(frame.temps_base);
230        self.temps_written.truncate(frame.temps_base);
231        self.containers.truncate(frame.containers_base);
232        Some(frame)
233    }
234
235    pub fn last(&self) -> Option<&CallFrame> {
236        self.frames.last()
237    }
238
239    pub fn last_mut(&mut self) -> Option<&mut CallFrame> {
240        self.frames.last_mut()
241    }
242
243    pub fn len(&self) -> usize {
244        self.frames.len()
245    }
246
247    pub fn is_empty(&self) -> bool {
248        self.frames.is_empty()
249    }
250
251    /// Depth index of the top frame, if any.
252    pub fn top_depth(&self) -> Option<usize> {
253        self.frames.len().checked_sub(1)
254    }
255
256    pub fn get(&self, depth: usize) -> Option<&CallFrame> {
257        self.frames.get(depth)
258    }
259
260    // ── Temp slots ─────────────────────────────────────────────────────
261
262    /// The storage range of frame `depth`'s temp segment.
263    fn temp_range(&self, depth: usize) -> Option<Range<usize>> {
264        let start = self.frames.get(depth)?.temps_base;
265        let end = self
266            .frames
267            .get(depth + 1)
268            .map_or(self.temps.len(), |next| next.temps_base);
269        Some(start..end)
270    }
271
272    /// Frame `depth`'s temp slots; empty for a frame that does not exist.
273    pub fn temps(&self, depth: usize) -> &[Value] {
274        self.temp_range(depth)
275            .and_then(|r| self.temps.get(r))
276            .unwrap_or(&[])
277    }
278
279    /// Temp slot `slot` of frame `depth`, if the segment covers it.
280    pub fn temp(&self, depth: usize, slot: usize) -> Option<&Value> {
281        self.temps(depth).get(slot)
282    }
283
284    /// Whether temp `slot` of frame `depth` has ever been the target of
285    /// [`Self::write_temp`]. A slot past the segment's end was never written.
286    #[must_use]
287    pub fn is_temp_written(&self, depth: usize, slot: usize) -> bool {
288        self.temp_range(depth)
289            .and_then(|r| self.temps_written.get(r))
290            .and_then(|written| written.get(slot))
291            .copied()
292            .unwrap_or(false)
293    }
294
295    /// Grow frame `depth`'s segment so that `slot` is covered, padding with
296    /// `Value::Null` (unwritten). Returns the storage index of `slot`, or
297    /// `None` if the frame does not exist.
298    fn ensure_temp(&mut self, depth: usize, slot: usize) -> Option<usize> {
299        let range = self.temp_range(depth)?;
300        if slot < range.len() {
301            return Some(range.start + slot);
302        }
303        let grow = slot + 1 - range.len();
304        if depth + 1 == self.frames.len() {
305            // The top frame: its segment ends at the end of the storage.
306            self.temps.resize(range.end + grow, Value::Null);
307            self.temps_written.resize(range.end + grow, false);
308        } else {
309            // A lower frame, reached through a `ref` parameter: open a gap
310            // at its segment's end and shift everything above it. See the
311            // type doc — exact, and effectively never taken.
312            self.temps.splice(
313                range.end..range.end,
314                core::iter::repeat_n(Value::Null, grow),
315            );
316            self.temps_written
317                .splice(range.end..range.end, core::iter::repeat_n(false, grow));
318            for frame in &mut self.frames[depth + 1..] {
319                frame.temps_base += grow;
320            }
321        }
322        Some(range.start + slot)
323    }
324
325    /// Write `val` into temp `slot` of frame `depth`, growing the segment
326    /// as needed, and mark the slot written. The single path every real
327    /// temp-slot store in the VM funnels through, so `GetTemp`'s "was this
328    /// ever written" check (issue #3354) stays accurate without every call
329    /// site having to remember the bitmap. A write to a frame that does
330    /// not exist is dropped.
331    pub fn write_temp(&mut self, depth: usize, slot: usize, val: Value) {
332        if let Some(i) = self.ensure_temp(depth, slot) {
333            self.temps[i] = val;
334            self.temps_written[i] = true;
335        }
336    }
337
338    /// Move temp `slot` of frame `depth` out, leaving `Value::Null` behind
339    /// (the written bit is untouched — `TakeTemp` leaves `Null` by design).
340    /// The segment grows to cover the slot exactly as a write would; a
341    /// slot that never existed yields `Null`.
342    pub fn take_temp(&mut self, depth: usize, slot: usize) -> Value {
343        match self.ensure_temp(depth, slot) {
344            Some(i) => core::mem::replace(&mut self.temps[i], Value::Null),
345            None => Value::Null,
346        }
347    }
348
349    /// Move the top frame's whole temp segment out, leaving it empty.
350    pub fn take_top_temps(&mut self) -> Vec<Value> {
351        let Some(base) = self.frames.last().map(|f| f.temps_base) else {
352            return Vec::new();
353        };
354        self.temps_written.truncate(base);
355        self.temps.drain(base..).collect()
356    }
357
358    /// Test seam: clear the written bit of one slot, to stage the
359    /// "declared but never written" state a real program reaches only
360    /// through `DeclareTemp`'s padding.
361    #[cfg(test)]
362    pub fn clear_temp_written(&mut self, depth: usize, slot: usize) {
363        if let Some(r) = self.temp_range(depth)
364            && let Some(bit) = self.temps_written.get_mut(r.start + slot)
365        {
366            *bit = false;
367        }
368    }
369
370    // ── Container positions ────────────────────────────────────────────
371
372    /// The storage range of frame `depth`'s container segment.
373    fn container_range(&self, depth: usize) -> Option<Range<usize>> {
374        let start = self.frames.get(depth)?.containers_base;
375        let end = self
376            .frames
377            .get(depth + 1)
378            .map_or(self.containers.len(), |next| next.containers_base);
379        Some(start..end)
380    }
381
382    /// Frame `depth`'s container positions, outermost first; empty for a
383    /// frame that does not exist.
384    pub fn containers(&self, depth: usize) -> &[ContainerPosition] {
385        self.container_range(depth)
386            .and_then(|r| self.containers.get(r))
387            .unwrap_or(&[])
388    }
389
390    /// Where the top frame is executing: the innermost position of its
391    /// container segment. `None` when the stack is empty or the top frame's
392    /// segment is (the frame is exhausted).
393    pub fn top_container(&self) -> Option<ContainerPosition> {
394        let base = self.frames.last()?.containers_base;
395        (self.containers.len() > base).then(|| self.containers[self.containers.len() - 1])
396    }
397
398    /// Mutable form of [`Self::top_container`].
399    pub fn top_container_mut(&mut self) -> Option<&mut ContainerPosition> {
400        let base = self.frames.last()?.containers_base;
401        (self.containers.len() > base).then(|| {
402            let last = self.containers.len() - 1;
403            &mut self.containers[last]
404        })
405    }
406
407    /// The top frame's container positions, outermost first.
408    pub fn top_containers(&self) -> &[ContainerPosition] {
409        self.frames
410            .last()
411            .and_then(|f| self.containers.get(f.containers_base..))
412            .unwrap_or(&[])
413    }
414
415    /// Enter a nested container in the top frame.
416    pub fn push_container(&mut self, pos: ContainerPosition) {
417        if !self.frames.is_empty() {
418            self.containers.push(pos);
419        }
420    }
421
422    /// Leave the top frame's innermost container. A no-op when the frame's
423    /// segment is already empty — never reaches into the frame below.
424    pub fn pop_container(&mut self) -> Option<ContainerPosition> {
425        let base = self.frames.last()?.containers_base;
426        (self.containers.len() > base)
427            .then(|| self.containers.pop())
428            .flatten()
429    }
430
431    /// Replace the top frame's whole container segment with `pos`: a jump
432    /// out of whatever nesting it was in.
433    pub fn reset_top_containers(&mut self, pos: ContainerPosition) {
434        if let Some(base) = self.frames.last().map(|f| f.containers_base) {
435            self.containers.truncate(base);
436            self.containers.push(pos);
437        }
438    }
439
440    /// Unwind the top frame's container segment to its first `keep`
441    /// positions, then set the innermost remaining position's offset — a
442    /// break divert back into an enclosing container.
443    pub fn unwind_top_containers(&mut self, keep: usize, offset: usize) {
444        if let Some(base) = self.frames.last().map(|f| f.containers_base) {
445            self.containers.truncate(base + keep);
446            if let Some(top) = self.containers.get_mut(base..).and_then(<[_]>::last_mut) {
447                top.offset = offset;
448            }
449        }
450    }
451}
452
453/// A single execution thread with its own call stack.
454#[derive(Debug, Clone)]
455pub(crate) struct Thread {
456    pub call_stack: CallStack,
457    /// How many frames at the bottom of `call_stack` belong to the parent
458    /// this thread was forked from — the mark that says where *this*
459    /// thread's own execution begins. `0` for the root thread; for a
460    /// thread spawned by `<-`, the parent's depth at the fork.
461    ///
462    /// This replaces the boundary `CallFrameType::Thread` frame `<-` used
463    /// to push (issue #3561). That frame was never released: selecting a
464    /// choice raised inside a thread installs the thread's fork wholesale
465    /// (`FlowInstance::select_choice`), so the boundary rode into the main
466    /// call stack and stayed there — one retained frame per turn in the
467    /// ordinary `<- thread`-as-choice game loop, with every subsequent
468    /// fork O(depth) against a depth that rose with the turn count. The
469    /// mark belongs on the thread, which is popped whole, rather than in
470    /// the stack, which gets copied and installed elsewhere.
471    pub base_depth: usize,
472}
473
474/// How the choice display text is stored internally.
475#[derive(Debug, Clone)]
476pub(crate) enum ChoiceDisplay {
477    /// Eagerly resolved text (legacy path, converter, or non-fragment codegen).
478    Text(String),
479    /// Index into the output buffer's fragment store — resolved on demand.
480    Fragment(u32),
481}
482
483#[derive(Debug, Clone)]
484pub(crate) struct PendingChoice {
485    pub display: ChoiceDisplay,
486    pub target_id: DefinitionId,
487    pub target_idx: u32,
488    pub target_offset: usize,
489    pub flags: ChoiceFlags,
490    #[expect(
491        dead_code,
492        reason = "needs research — likely needed for structured output / voice acting"
493    )]
494    pub original_index: usize,
495    /// Tags collected during choice evaluation.
496    pub tags: Vec<String>,
497    /// Snapshot of the current thread at choice creation time, so that
498    /// selecting this choice can restore the execution context
499    /// (including temp variables from enclosing tunnels/functions).
500    pub thread_fork: Thread,
501}
502
503/// The dev/prod execution mode (NS-A4, `docs/stdlib-spec.md` §4b, ruled
504/// 2026-07-18): the knob that decides WHERE execution stops on an unordered
505/// comparand — never WHAT values are fabricated.
506///
507/// The split is **fenced to placement**: it exists only where the prod
508/// behavior is defined, total, and fabricates no data. Ordering contexts
509/// qualify (`sort`/`sorted`/`min`/`max`; A7 adds `heap_push`): every element
510/// is preserved, the order is deterministic, saves/replay are safe.
511/// Fabrication never qualifies — `int("potato")`, OOB indexing stay
512/// always-fault in both modes. Effect rows are mode-independent (the checker
513/// doesn't know modes exist).
514///
515/// - [`Dev`](Self::Dev) (the default — the Rust dev-profile analogy, like
516///   debug-build overflow checks): a float NaN comparand in an ordering
517///   context is a turn-terminating [`RuntimeError::UnorderedComparand`]
518///   fault, surfacing the upstream bug at its first ordering consumption.
519/// - [`Prod`](Self::Prod): the pinned non-fabricating total order applies —
520///   ordinary IEEE order with `-0 == +0` as a tie, NaN greater than
521///   everything, NaN-vs-NaN ties (deliberately NOT IEEE `totalOrder`, whose
522///   `-0 < +0` would split ordering from `==` on clean data). Execution
523///   keeps moving.
524///
525/// On NaN-free data the modes agree exactly and cohere with `<`/`==`.
526///
527/// The knob's *home* is project config (`brink.toml` profile) with a
528/// host-API override (ruled 2026-07-19; tooling wires the config side).
529/// This runtime mechanism is the host-API leg: set it via
530/// [`Story::set_exec_mode`] / [`FlowInstance::set_exec_mode`]. The mode is
531/// a host/build knob, not story state — it is never embedded in `.inkb`
532/// (mirroring `dialect`/`types`) and never persisted in saves.
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
534pub enum ExecMode {
535    /// Fault on unordered comparands (NaN) in ordering contexts.
536    #[default]
537    Dev,
538    /// Keep moving: place NaN by the pinned non-fabricating total order.
539    Prod,
540}
541
542/// Per-flow execution context. Owns threads, eval stack, output, choices.
543#[derive(Debug, Clone)]
544#[expect(
545    clippy::struct_excessive_bools,
546    reason = "VM flags are inherently boolean"
547)]
548pub(crate) struct Flow {
549    pub threads: Vec<Thread>,
550    pub value_stack: Vec<Value>,
551    pub output: OutputBuffer,
552    pub pending_choices: Vec<PendingChoice>,
553    pub current_tags: Vec<String>,
554    pub in_tag: bool,
555    pub skipping_choice: bool,
556    /// Set to `true` when a `Done` opcode fires (explicit `-> DONE`).
557    /// Cleared at the start of each `continue_single` call.
558    pub did_safe_exit: bool,
559    /// Set to `true` when a `Yield` opcode falls through with no
560    /// pending choices — the story passed through an empty choice set.
561    /// Cleared at the start of each `continue_single` call.
562    pub did_unsafe_yield: bool,
563    /// Has a `Step::Line` been handed out since this turn began (issue
564    /// #3533)? Set by `make_output_line`, cleared wherever `next_block_id`
565    /// starts a fresh run (a choice, a resume from `Done`, a host jump).
566    /// Decides whether trailing blank lines at a yield are ink's dropped
567    /// lookahead (yes) or a turn's first — and kept — `Continue` (no).
568    pub line_delivered_this_turn: bool,
569    /// The call-stack-derived cause captured the moment execution hit the
570    /// content-exhaustion boundary ([`crate::vm::handle_frame_exhaustion`])
571    /// that produced the terminal `Done` — mirrors C#'s inline
572    /// `CanPop(Tunnel)`/`CanPop(Function)`/`!canPop` selection (`Story.cs`)
573    /// at the instant it happens, before this runtime's own frame unwinding
574    /// (which, unlike C#, always pops the exhausted frame — see the type's
575    /// own docs) can erase the evidence. Read by
576    /// [`FlowInstance::advance_with_limit`](crate::story::FlowInstance::advance_with_limit)'s
577    /// deferred "ran out of content" fault one `continue_single` call
578    /// later. Written *only* on the exhaustion paths that themselves return
579    /// `Done` — an exhaustion that instead resumes execution (a completed
580    /// thread with a parent to fall back to, a popped frame with content
581    /// still below it) never touches this field, so a transient exhaustion
582    /// elsewhere on the same flow (e.g. a `Story::call_function` boundary
583    /// evaluating a function that calls a void helper) can't clobber a
584    /// cause an earlier, still-pending exhaustion already recorded. Not
585    /// cleared between cycles like the two flags above it — it is
586    /// meaningless unless `did_safe_exit` is `false` at the same `Done`,
587    /// which is the only condition under which it is ever read.
588    pub ran_out_of_content_cause: RanOutOfContentCause,
589    /// The dev/prod execution mode (NS-A4, [`ExecMode`]). A host/build
590    /// knob, not story state — never persisted; defaults to
591    /// [`ExecMode::Dev`].
592    pub exec_mode: ExecMode,
593    /// In-flight nested **pure-callback** evaluation state — see
594    /// [`PureCallbackState`].
595    pub pure_callback: PureCallbackState,
596    /// The [`super::types::BlockId`] stamped onto the next `Step::Line`
597    /// produced by this flow — counts uninterrupted runs of adjacent
598    /// content (`docs/prose-dialect-spec.md` §3.7/§8d.2). Bumped whenever a
599    /// new run begins: after a choice is selected, after resuming from
600    /// `Done`, and on a host-directed jump (`choose_path_string`, which is
601    /// itself specified as force-completing the current flow like `->
602    /// DONE`).
603    ///
604    /// ⚠ **Persistence is boundary-dependent, not uniformly "never" (the
605    /// 2026-08-05 ruling on #2108, `docs/decision-log.md`).** This field's
606    /// doc previously said outright that it is *never* persisted — that
607    /// claim was true only for the *ordinary* save (`Story::save_state`/
608    /// `load_state`, `brink_runtime::save`'s free functions): that save
609    /// captures game state only, the host re-enters at a known knot on
610    /// load, and a fresh `0` there is genuinely harmless because nothing
611    /// ever compares a block id *across* that boundary — a brand-new run
612    /// starting fresh is exactly what re-entering a knot means. **That part
613    /// of the old claim still holds and this field is still not part of
614    /// `SaveState` itself.**
615    ///
616    /// It stopped being true unconditionally once element attachment
617    /// (`@[convention(..., attach = X)]`, #2108) could leave a run *open*
618    /// across a suspension: a block is not just lines — executable
619    /// statements can interleave with an attach-scoped dialogue run, and
620    /// `Step::Suspended` is deliberately not one of the run-terminators
621    /// above (an `await` can fire mid-run). A flow parked there is resumed
622    /// from its exact execution position via `brink_format::SuspendedFlow`
623    /// (the `FlowFrame`, `docs/flow-suspension-spec.md` §2/§9) — genuinely
624    /// continuing the SAME run, not re-entering a knot from the top — so a
625    /// numbering restart at `0` there would silently collide with (or just
626    /// diverge from) the pre-park sequence, and the run's element data
627    /// (`crate::output::OutputBuffer`'s carried-forward attachment state)
628    /// would reset to empty, dropping the attributed speaker. For that
629    /// boundary only, this value **is** persisted — see
630    /// `SuspendedFlow::next_block_id`/`SuspendedFlow::pending_element`'s own
631    /// docs — even though the ordinary game-state save still never touches
632    /// it.
633    pub next_block_id: u64,
634    /// A terminal ([`super::types::Step`] variant with no line payload)
635    /// computed but not yet delivered, because its trailing content needed
636    /// to go out first as an ordinary `Step::Line` (terminals carry no
637    /// text — `docs/prose-dialect-spec.md` §7). Consumed and returned bare
638    /// on the very next `advance` call, with no VM stepping — see
639    /// [`PendingTerminal`] for the invalidation invariant this type
640    /// enforces.
641    pub pending_terminal: PendingTerminal,
642    /// Non-fatal conditions this flow reported while running (issue #3354),
643    /// drained by the host through
644    /// [`FlowInstance::take_runtime_warnings`](crate::FlowInstance::take_runtime_warnings).
645    ///
646    /// Lives on the flow rather than on [`super::Stats`] because it is
647    /// execution *output*, not a counter, and because every VM site that
648    /// can raise one already holds `&mut Flow` — no new parameter has to be
649    /// threaded through `vm::step` for it. Capped at
650    /// [`crate::RUNTIME_WARNING_CAP`] entries between drains
651    /// ([`Self::warn`]).
652    pub warnings: Vec<crate::error::RuntimeWarning>,
653}
654
655/// A terminal computed for the current run but held back because its
656/// trailing content had to flush first as its own `Step::Line` (terminals
657/// carry no text of their own — `docs/prose-dialect-spec.md` §7). Stamped
658/// with the [`Flow::next_block_id`] value current at the moment it was
659/// computed.
660///
661/// **Invariant this type exists to enforce** (the bug found in #1684's
662/// review, filed as #2104): a stashed terminal must never be handed back
663/// after a host-directed jump or choice has moved execution somewhere else
664/// in the meantime — `choose`/`choose_path_string`/`choose_path_string_with_args`
665/// all force-complete the current run and begin a fresh one (bumping
666/// `next_block_id`, per that field's own doc comment: "Bumped whenever a
667/// new run begins: after a choice is selected, after resuming from `Done`,
668/// and on a host-directed jump"). [`take_if_current`](Self::take_if_current)
669/// compares the stash's stamp against the block id current *at read time*
670/// and silently discards a stale stash rather than returning it — so the
671/// invariant holds **by construction**: any call site that begins a new run
672/// only has to keep bumping `next_block_id` for its own reasons (block-id
673/// correctness for `Step::Line`, already required whether or not this type
674/// existed), and pending-terminal invalidation falls out for free. No call
675/// site needs its own `= None` clear, so a future host-directed mutation
676/// (a rewind, a fast-forward) that begins a new run cannot reintroduce this
677/// bug by forgetting one.
678///
679/// `Story::load_state`/the free `load_state` function are **not** part of
680/// this invariant's surface: they reconcile only game state (globals,
681/// visit/turn counts) into a `ContextAccess`, never touching `Flow` or its
682/// `next_block_id`/`pending_terminal` at all — so they cannot leave a stale
683/// stash behind, and need no clear of their own. See
684/// `docs/runtime-spec.md`'s pending-terminal section.
685#[derive(Debug, Clone, Default)]
686pub(crate) struct PendingTerminal(Option<(u64, super::types::Step)>);
687
688impl PendingTerminal {
689    /// Stash `terminal`, stamped with the run (`next_block_id`) it was
690    /// computed under.
691    pub(crate) fn stash(&mut self, block_id: u64, terminal: super::types::Step) {
692        self.0 = Some((block_id, terminal));
693    }
694
695    /// Take the stashed terminal iff its stamp matches `current_block_id` —
696    /// i.e. no new run has begun since it was stashed. Always empties the
697    /// slot (fresh or stale), so a stale stash can never be read twice.
698    pub(crate) fn take_if_current(&mut self, current_block_id: u64) -> Option<super::types::Step> {
699        self.0
700            .take()
701            .and_then(|(stamp, terminal)| (stamp == current_block_id).then_some(terminal))
702    }
703}
704
705/// Transient bookkeeping for in-flight nested callback evaluations: a
706/// `sort_by`/`sorted_by` comparator (NS-A4), a pure fn-value verb's callback
707/// (`map`/`filter`/`fold`/`filter_map` — `docs/stdlib-spec.md` §4, issue
708/// #1679), or an effectful fn-value verb's callback (`each`/`map_each`,
709/// issue #1679 slice 2). All three re-enter [`crate::vm::step`] from inside
710/// a single opcode, so they share one counter — `effectful` is what splits
711/// the two runtime contracts without splitting the bookkeeping.
712///
713/// Never persisted (a callback always completes within the opcode that
714/// started it). The depth guards Rust stack recursion when a callback itself
715/// runs a callback verb, regardless of which contract; the verb name is
716/// what the dev-mode world-write guard reports.
717#[derive(Debug, Clone, Copy, Default)]
718pub(crate) struct PureCallbackState {
719    /// Number of nested callback evaluations currently on the Rust stack
720    /// (pure and effectful alike).
721    pub depth: u16,
722    /// The source spelling of the innermost verb whose callback is running
723    /// (`"sort_by"`, `"map"`, `"each"`, …). Only meaningful while
724    /// `depth > 0`; the default `""` is never read.
725    pub verb: &'static str,
726    /// Whether the innermost running callback is the **effectful** contract
727    /// (`each`/`map_each`): output reaches the transcript instead of being
728    /// captured, and [`crate::vm`]'s dev-mode world-write guard is disarmed
729    /// for it. `false` for the pure quartet and for `sort_by`/`sorted_by`.
730    /// Only meaningful while `depth > 0`.
731    pub effectful: bool,
732}
733
734impl Flow {
735    /// Record a non-fatal runtime condition, up to
736    /// [`crate::RUNTIME_WARNING_CAP`] entries between drains. Beyond the
737    /// cap the warning is dropped rather than growing the list without
738    /// bound — the earlier entries already say what the author needs.
739    pub fn warn(&mut self, warning: crate::error::RuntimeWarning) {
740        if self.warnings.len() < crate::RUNTIME_WARNING_CAP {
741            self.warnings.push(warning);
742        }
743    }
744
745    /// Returns a reference to the current (topmost) thread.
746    ///
747    /// # Panics
748    ///
749    /// Panics if the thread stack is empty. This is a programming error —
750    /// flows are always constructed with at least one thread.
751    #[expect(clippy::expect_used)]
752    pub fn current_thread(&self) -> &Thread {
753        self.threads
754            .last()
755            .expect("flow must always have at least one thread")
756    }
757
758    /// Returns a mutable reference to the current (topmost) thread.
759    ///
760    /// # Panics
761    ///
762    /// Panics if the thread stack is empty. This is a programming error —
763    /// flows are always constructed with at least one thread.
764    #[expect(clippy::expect_used)]
765    pub fn current_thread_mut(&mut self) -> &mut Thread {
766        self.threads
767            .last_mut()
768            .expect("flow must always have at least one thread")
769    }
770
771    pub fn can_pop_thread(&self) -> bool {
772        self.threads.len() > 1
773    }
774
775    /// Returns `true` if a `FunctionEvalFromGame` boundary frame is present
776    /// in the current thread's call stack — i.e. an engine→ink function
777    /// evaluation is still in progress. Functions don't fork threads, so
778    /// the current thread is where the boundary lives. The eval driver
779    /// uses this to detect when the function has returned (boundary popped).
780    pub fn has_eval_boundary(&self) -> bool {
781        let cs = &self.current_thread().call_stack;
782        (0..cs.len())
783            .filter_map(|i| cs.get(i))
784            .any(|f| f.frame_type == CallFrameType::FunctionEvalFromGame)
785    }
786
787    pub fn pop_thread(&mut self) {
788        self.threads.pop();
789    }
790
791    /// Fork a new thread from the current one. Returns `(thread, snapshot_cache_hit)`.
792    ///
793    /// The fork starts with `base_depth: 0` — the root-thread value, which
794    /// is what a choice fork needs, since selecting a choice installs it as
795    /// the flow's only thread. `Opcode::ThreadCall` overwrites it with the
796    /// parent's depth for a `<-` spawn.
797    pub fn fork_thread(&mut self) -> Thread {
798        Thread {
799            call_stack: self.current_thread().call_stack.clone(),
800            base_depth: 0,
801        }
802    }
803
804    /// Is the current thread one spawned by `<-`, standing at its own base
805    /// — holding no frame above the ones it inherited from its parent?
806    ///
807    /// This is the question the boundary `CallFrameType::Thread` frame used
808    /// to answer by being on top of the stack (issue #3561, see
809    /// [`Thread::base_depth`]). Two callers need it: content exhaustion
810    /// here means the *thread* is done (pop it whole; never unwind into the
811    /// parent's frames below), and the debugger refuses step-out there —
812    /// `docs/debugger-spec.md` §4's ruled `Thread` row, "a thread is not a
813    /// frame you can return from".
814    pub fn at_thread_base(&self) -> bool {
815        let thread = self.current_thread();
816        self.can_pop_thread() && thread.call_stack.len() <= thread.base_depth
817    }
818
819    /// Read the arguments from the top External frame.
820    pub fn external_args(&self) -> &[Value] {
821        let stack = &self.current_thread().call_stack;
822        match stack.last() {
823            Some(f) if f.frame_type == CallFrameType::External => stack.temps(stack.len() - 1),
824            _ => &[],
825        }
826    }
827
828    /// Read the external function's `DefinitionId` from the top External frame.
829    pub fn external_fn_id(&self) -> Option<DefinitionId> {
830        let frame = self.current_thread().call_stack.last()?;
831        if frame.frame_type == CallFrameType::External {
832            frame.external_fn_id
833        } else {
834            None
835        }
836    }
837
838    /// Resolve an external call: pop the External frame and push the
839    /// return value onto the value stack.
840    pub fn resolve_external(&mut self, value: Value) {
841        let thread = self.current_thread_mut();
842        if let Some(frame) = thread.call_stack.last()
843            && frame.frame_type == CallFrameType::External
844        {
845            let ret_addr = frame.return_address;
846            thread.call_stack.pop();
847            self.value_stack.push(value);
848            // Restore position from return address (if any).
849            if let Some(pos) = ret_addr
850                && let Some(top) = self.current_thread_mut().call_stack.top_container_mut()
851            {
852                *top = pos;
853            }
854        }
855    }
856
857    /// Replace the External frame with a Function frame pointing at the
858    /// fallback container. Args are pushed back onto the value stack so
859    /// the fallback body's `temp=` opcodes can pop them.
860    pub fn invoke_fallback(&mut self, container_idx: u32, param_slots: &[u16]) {
861        let output_start = self.output.mark();
862        let Some(thread) = self.threads.last_mut() else {
863            return;
864        };
865        let stack = &mut thread.call_stack;
866        if stack
867            .last()
868            .is_some_and(|frame| frame.frame_type == CallFrameType::External)
869        {
870            let args = stack.take_top_temps();
871            if let Some(frame) = stack.last_mut() {
872                frame.frame_type = CallFrameType::Function;
873                frame.external_fn_id = None;
874                frame.function_output_start = Some(output_start);
875            }
876            stack.reset_top_containers(ContainerPosition {
877                container_idx,
878                offset: 0,
879            });
880            // Push the external call's arguments back onto the value stack
881            // and bind them into the fallback's parameter slots. `.inkb` v10
882            // removed the `DeclareTemp` prologue that used to do the second
883            // half; doing it here rather than writing the list straight into
884            // the slots keeps the old behaviour when the argument count and
885            // the fallback's arity disagree — the surplus stays on the stack.
886            let depth = stack.top_depth();
887            self.value_stack.extend(args);
888            let last = self.threads.len() - 1;
889            if let Some(depth) = depth {
890                let stack = &mut self.threads[last].call_stack;
891                for slot in param_slots.iter().rev() {
892                    let Some(val) = self.value_stack.pop() else {
893                        break;
894                    };
895                    stack.write_temp(depth, usize::from(*slot), val);
896                }
897            }
898        }
899    }
900
901    /// Pop a value from the value stack.
902    pub fn pop_value(&mut self) -> Result<Value, RuntimeError> {
903        self.value_stack
904            .pop()
905            .ok_or_else(|| RuntimeError::StackUnderflow)
906    }
907
908    /// Peek at the top value without popping.
909    pub fn peek_value(&self) -> Result<&Value, RuntimeError> {
910        self.value_stack
911            .last()
912            .ok_or_else(|| RuntimeError::StackUnderflow)
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    use crate::story::Step;
920
921    // ── CallStack: the contiguous layout ─────────────────────────────────
922    //
923    // Every frame's temps and container positions live in one shared
924    // `Vec` per kind (see `CallStack`'s doc). These pin the segment
925    // arithmetic: a frame sees exactly its own slots, popping releases
926    // exactly its own storage, and the one slow path — growing a frame
927    // that is not on top — shifts the frames above it correctly.
928
929    fn pos(container_idx: u32, offset: usize) -> ContainerPosition {
930        ContainerPosition {
931            container_idx,
932            offset,
933        }
934    }
935
936    fn function_frame() -> CallFrame {
937        CallFrame::new(CallFrameType::Function, Some(pos(0, 7)), None)
938    }
939
940    #[test]
941    fn frames_see_only_their_own_temp_segments() {
942        let mut stack = CallStack::new(
943            CallFrame::new(CallFrameType::Root, None, None),
944            Some(pos(0, 0)),
945        );
946        stack.write_temp(0, 1, Value::Int(10));
947        stack.push(function_frame(), Some(pos(1, 0)));
948        stack.write_temp(1, 0, Value::Int(20));
949
950        assert_eq!(stack.temps(0), &[Value::Null, Value::Int(10)]);
951        assert_eq!(stack.temps(1), &[Value::Int(20)]);
952        assert!(!stack.is_temp_written(0, 0), "padding is not a write");
953        assert!(stack.is_temp_written(0, 1));
954        assert!(stack.is_temp_written(1, 0));
955        assert!(
956            !stack.is_temp_written(1, 1),
957            "past the segment's end is unwritten"
958        );
959        assert_eq!(stack.temp(1, 1), None);
960        assert_eq!(
961            stack.temps(2),
962            &[],
963            "a frame that does not exist has no slots"
964        );
965    }
966
967    #[test]
968    fn pop_releases_exactly_the_top_frame_storage() {
969        let mut stack = CallStack::new(
970            CallFrame::new(CallFrameType::Root, None, None),
971            Some(pos(0, 0)),
972        );
973        stack.write_temp(0, 0, Value::Int(1));
974        stack.push(function_frame(), Some(pos(1, 0)));
975        stack.write_temp(1, 3, Value::Int(2));
976        stack.push_container(pos(2, 5));
977
978        let popped = stack.pop().expect("a frame to pop");
979        assert_eq!(popped.frame_type, CallFrameType::Function);
980        assert_eq!(popped.return_address, Some(pos(0, 7)));
981        assert_eq!(stack.len(), 1);
982        assert_eq!(stack.temps(0), &[Value::Int(1)]);
983        assert_eq!(stack.top_containers(), &[pos(0, 0)]);
984        assert_eq!(stack.top_container(), Some(pos(0, 0)));
985    }
986
987    /// The slow path: a `ref` parameter writing a slot its owning frame
988    /// never declared, while a callee frame sits above it. The lower
989    /// segment grows in place and the callee's slots move with it, intact.
990    #[test]
991    fn growing_a_lower_frame_shifts_the_frames_above_it() {
992        let mut stack = CallStack::new(
993            CallFrame::new(CallFrameType::Root, None, None),
994            Some(pos(0, 0)),
995        );
996        stack.write_temp(0, 0, Value::Int(1));
997        stack.push(function_frame(), Some(pos(1, 0)));
998        stack.write_temp(1, 0, Value::Int(100));
999        stack.write_temp(1, 1, Value::Int(101));
1000        stack.push(function_frame(), Some(pos(2, 0)));
1001        stack.write_temp(2, 0, Value::Int(200));
1002
1003        stack.write_temp(0, 3, Value::Int(4));
1004
1005        assert_eq!(
1006            stack.temps(0),
1007            &[Value::Int(1), Value::Null, Value::Null, Value::Int(4)]
1008        );
1009        assert_eq!(stack.temps(1), &[Value::Int(100), Value::Int(101)]);
1010        assert_eq!(stack.temps(2), &[Value::Int(200)]);
1011        assert!(stack.is_temp_written(0, 3));
1012        assert!(!stack.is_temp_written(0, 2));
1013        assert!(stack.is_temp_written(1, 1));
1014        assert!(stack.is_temp_written(2, 0));
1015
1016        // And the same through `take_temp`, which grows identically.
1017        assert_eq!(stack.take_temp(1, 4), Value::Null);
1018        assert_eq!(stack.temps(1).len(), 5);
1019        assert_eq!(stack.temps(2), &[Value::Int(200)]);
1020        assert_eq!(stack.take_temp(2, 0), Value::Int(200));
1021        assert_eq!(stack.temps(2), &[Value::Null]);
1022        assert!(
1023            stack.is_temp_written(2, 0),
1024            "a take leaves the written bit alone"
1025        );
1026    }
1027
1028    #[test]
1029    fn external_frame_args_are_written_by_construction_and_movable() {
1030        let mut stack = CallStack::new(
1031            CallFrame::new(CallFrameType::Root, None, None),
1032            Some(pos(0, 0)),
1033        );
1034        stack.write_temp(0, 0, Value::Int(1));
1035        stack.push_with_args(
1036            CallFrame::external(
1037                DefinitionId::new(brink_format::DefinitionTag::Address, 9),
1038                Some(pos(0, 3)),
1039            ),
1040            vec![Value::Int(7), Value::Bool(true)],
1041        );
1042        assert_eq!(stack.temps(1), &[Value::Int(7), Value::Bool(true)]);
1043        assert!(stack.is_temp_written(1, 1));
1044        assert_eq!(
1045            stack.top_container(),
1046            None,
1047            "an external frame executes nowhere"
1048        );
1049
1050        let args = stack.take_top_temps();
1051        assert_eq!(args, vec![Value::Int(7), Value::Bool(true)]);
1052        assert_eq!(stack.temps(1), &[]);
1053        assert_eq!(
1054            stack.temps(0),
1055            &[Value::Int(1)],
1056            "the caller's slots are untouched"
1057        );
1058    }
1059
1060    #[test]
1061    fn container_operations_never_reach_the_frame_below() {
1062        let mut stack = CallStack::new(
1063            CallFrame::new(CallFrameType::Root, None, None),
1064            Some(pos(0, 0)),
1065        );
1066        stack.push_container(pos(1, 0));
1067        stack.push(function_frame(), None);
1068
1069        assert_eq!(stack.top_container(), None);
1070        assert_eq!(
1071            stack.pop_container(),
1072            None,
1073            "nothing to pop in an empty segment"
1074        );
1075        assert_eq!(stack.containers(0), &[pos(0, 0), pos(1, 0)]);
1076
1077        stack.push_container(pos(5, 0));
1078        stack.push_container(pos(6, 2));
1079        assert_eq!(stack.top_containers(), &[pos(5, 0), pos(6, 2)]);
1080        stack.unwind_top_containers(1, 9);
1081        assert_eq!(stack.top_containers(), &[pos(5, 9)]);
1082        stack.reset_top_containers(pos(8, 1));
1083        assert_eq!(stack.top_containers(), &[pos(8, 1)]);
1084        if let Some(top) = stack.top_container_mut() {
1085            top.offset = 4;
1086        }
1087        assert_eq!(stack.top_container(), Some(pos(8, 4)));
1088        assert_eq!(stack.containers(0), &[pos(0, 0), pos(1, 0)]);
1089
1090        stack.pop();
1091        assert_eq!(stack.top_container(), Some(pos(1, 0)));
1092    }
1093
1094    #[test]
1095    fn a_fork_is_an_independent_copy() {
1096        let mut stack = CallStack::new(
1097            CallFrame::new(CallFrameType::Root, None, None),
1098            Some(pos(0, 0)),
1099        );
1100        stack.push(function_frame(), Some(pos(1, 0)));
1101        stack.write_temp(1, 0, Value::Int(1));
1102        let mut fork = stack.clone();
1103        fork.write_temp(1, 0, Value::Int(2));
1104        fork.push(function_frame(), Some(pos(2, 0)));
1105        assert_eq!(stack.temps(1), &[Value::Int(1)]);
1106        assert_eq!(stack.len(), 2);
1107        assert_eq!(fork.temps(1), &[Value::Int(2)]);
1108        assert_eq!(fork.len(), 3);
1109    }
1110
1111    // ── PendingTerminal ───────────────────────────────────────────────────
1112    //
1113    // Unit-level coverage for the invalidation invariant itself (see
1114    // `PendingTerminal`'s own doc comment): a stash is only ever handed
1115    // back if the caller's current block id still matches the one it was
1116    // stamped with, and a read — fresh or stale — always empties the slot.
1117    // The `FlowInstance`-level regression tests for the actual bug this
1118    // guards against (a stashed terminal replaying after a host jump or a
1119    // choice) live in `brink-test-harness/tests/jump_to_path.rs`
1120    // (`jump_right_after_content_line_does_not_replay_stale_terminal`,
1121    // `choose_right_after_content_line_does_not_replay_stale_terminal`).
1122
1123    /// A stash read back under the SAME block id it was stamped with (the
1124    /// ordinary case: content flushes, then the terminal is delivered on
1125    /// the very next call, with no run boundary in between) is returned.
1126    #[test]
1127    fn take_if_current_returns_a_fresh_stash() {
1128        let mut pending = PendingTerminal::default();
1129        pending.stash(3, Step::Done);
1130        assert_eq!(pending.take_if_current(3), Some(Step::Done));
1131    }
1132
1133    /// A stash read back under a LATER block id than the one it was
1134    /// stamped with — exactly what happens after a host-directed jump or
1135    /// choice, both of which bump `next_block_id` before the flow is ever
1136    /// asked to advance again — is discarded rather than replayed. This is
1137    /// the mechanism that makes the invariant hold **without** a jump/choice
1138    /// call site needing its own explicit `= None` clear.
1139    #[test]
1140    fn take_if_current_discards_a_stash_stamped_for_an_earlier_block() {
1141        let mut pending = PendingTerminal::default();
1142        pending.stash(3, Step::Done);
1143        assert_eq!(
1144            pending.take_if_current(4),
1145            None,
1146            "a stash stamped for block 3 must not surface once the current \
1147             block has moved to 4"
1148        );
1149    }
1150
1151    /// Reading the slot always empties it — a stale stash discarded by one
1152    /// read can't somehow surface on a later read even if that later read
1153    /// happens to use the original stamp again.
1154    #[test]
1155    fn take_if_current_always_empties_the_slot_even_when_stale() {
1156        let mut pending = PendingTerminal::default();
1157        pending.stash(3, Step::Done);
1158        assert_eq!(pending.take_if_current(4), None, "first (stale) read");
1159        assert_eq!(
1160            pending.take_if_current(3),
1161            None,
1162            "the slot was already emptied by the stale read above — it must \
1163             not resurrect the old value just because the stamp is asked \
1164             for again"
1165        );
1166    }
1167
1168    /// An empty slot never produces a terminal, regardless of which block
1169    /// id is asked for.
1170    #[test]
1171    fn take_if_current_on_an_empty_slot_is_always_none() {
1172        let mut pending = PendingTerminal::default();
1173        assert_eq!(pending.take_if_current(0), None);
1174    }
1175
1176    /// A tunnel frame that can still pop classifies as `Tunnel` — mirrors
1177    /// C#'s `callStack.CanPop(PushPopType.Tunnel)` arm.
1178    #[test]
1179    fn classify_tunnel_with_can_pop_is_tunnel() {
1180        assert_eq!(
1181            classify_ran_out_of_content(CallFrameType::Tunnel, true),
1182            RanOutOfContentCause::Tunnel
1183        );
1184    }
1185
1186    /// A function frame that can still pop classifies as `Function` —
1187    /// mirrors C#'s `callStack.CanPop(PushPopType.Function)` arm.
1188    #[test]
1189    fn classify_function_with_can_pop_is_function() {
1190        assert_eq!(
1191            classify_ran_out_of_content(CallFrameType::Function, true),
1192            RanOutOfContentCause::Function
1193        );
1194    }
1195
1196    /// Any other frame type that can still pop (a `FunctionEvalFromGame`
1197    /// boundary, even `Root`/`External`) falls to the "unknown reason"
1198    /// backstop — mirrors C#'s final `else` arm.
1199    #[test]
1200    fn classify_other_frame_types_with_can_pop_is_unknown() {
1201        for frame_type in [
1202            CallFrameType::Root,
1203            CallFrameType::External,
1204            CallFrameType::FunctionEvalFromGame,
1205        ] {
1206            assert_eq!(
1207                classify_ran_out_of_content(frame_type, true),
1208                RanOutOfContentCause::Unknown,
1209                "frame type {frame_type:?} with can_pop=true should classify as Unknown"
1210            );
1211        }
1212    }
1213
1214    /// A call stack that can't pop at all — regardless of the exhausted
1215    /// frame's type — is the plain "story fell off the end" case. Mirrors
1216    /// C#'s `!callStack.canPop` arm, which is checked before frame-type
1217    /// distinctions are even considered.
1218    #[test]
1219    fn classify_cannot_pop_is_always_plain() {
1220        for frame_type in [
1221            CallFrameType::Root,
1222            CallFrameType::Function,
1223            CallFrameType::Tunnel,
1224            CallFrameType::External,
1225            CallFrameType::FunctionEvalFromGame,
1226        ] {
1227            assert_eq!(
1228                classify_ran_out_of_content(frame_type, false),
1229                RanOutOfContentCause::Plain,
1230                "frame type {frame_type:?} with can_pop=false should classify as Plain"
1231            );
1232        }
1233    }
1234}