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}