use alloc::string::String;
use alloc::vec::Vec;
use brink_format::{ChoiceFlags, DefinitionId, Value};
use core::ops::Range;
use crate::error::{RanOutOfContentCause, RuntimeError};
use crate::output::{OutputBuffer, OutputMark};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ContainerPosition {
pub container_idx: u32,
pub offset: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CallFrameType {
Root,
Function,
Tunnel,
External,
FunctionEvalFromGame,
}
pub(crate) fn classify_ran_out_of_content(
frame_type: CallFrameType,
can_pop: bool,
) -> RanOutOfContentCause {
if can_pop && frame_type == CallFrameType::Tunnel {
RanOutOfContentCause::Tunnel
} else if can_pop && frame_type == CallFrameType::Function {
RanOutOfContentCause::Function
} else if can_pop {
RanOutOfContentCause::Unknown
} else {
RanOutOfContentCause::Plain
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CallFrame {
pub return_address: Option<ContainerPosition>,
pub frame_type: CallFrameType,
pub external_fn_id: Option<DefinitionId>,
pub function_output_start: Option<OutputMark>,
temps_base: usize,
containers_base: usize,
}
impl CallFrame {
pub fn new(
frame_type: CallFrameType,
return_address: Option<ContainerPosition>,
function_output_start: Option<OutputMark>,
) -> Self {
Self {
return_address,
frame_type,
external_fn_id: None,
function_output_start,
temps_base: 0,
containers_base: 0,
}
}
pub fn external(fn_id: DefinitionId, return_address: Option<ContainerPosition>) -> Self {
Self {
external_fn_id: Some(fn_id),
..Self::new(CallFrameType::External, return_address, None)
}
}
}
const TEMPS_RESERVE: usize = 64;
const CONTAINERS_RESERVE: usize = 16;
const FRAMES_RESERVE: usize = 8;
#[derive(Debug, Clone)]
pub(crate) struct CallStack {
frames: Vec<CallFrame>,
temps: Vec<Value>,
temps_written: Vec<bool>,
containers: Vec<ContainerPosition>,
}
impl CallStack {
pub fn new(root: CallFrame, entry: Option<ContainerPosition>) -> Self {
let mut stack = Self {
frames: Vec::with_capacity(FRAMES_RESERVE),
temps: Vec::with_capacity(TEMPS_RESERVE),
temps_written: Vec::with_capacity(TEMPS_RESERVE),
containers: Vec::with_capacity(CONTAINERS_RESERVE),
};
stack.push(root, entry);
stack
}
pub fn push(&mut self, mut frame: CallFrame, entry: Option<ContainerPosition>) {
frame.temps_base = self.temps.len();
frame.containers_base = self.containers.len();
self.frames.push(frame);
if let Some(pos) = entry {
self.containers.push(pos);
}
}
pub fn push_with_args(&mut self, frame: CallFrame, args: Vec<Value>) {
self.push(frame, None);
self.temps_written
.resize(self.temps.len() + args.len(), true);
self.temps.extend(args);
}
pub fn pop(&mut self) -> Option<CallFrame> {
let frame = self.frames.pop()?;
self.temps.truncate(frame.temps_base);
self.temps_written.truncate(frame.temps_base);
self.containers.truncate(frame.containers_base);
Some(frame)
}
pub fn last(&self) -> Option<&CallFrame> {
self.frames.last()
}
pub fn last_mut(&mut self) -> Option<&mut CallFrame> {
self.frames.last_mut()
}
pub fn len(&self) -> usize {
self.frames.len()
}
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
pub fn top_depth(&self) -> Option<usize> {
self.frames.len().checked_sub(1)
}
pub fn get(&self, depth: usize) -> Option<&CallFrame> {
self.frames.get(depth)
}
fn temp_range(&self, depth: usize) -> Option<Range<usize>> {
let start = self.frames.get(depth)?.temps_base;
let end = self
.frames
.get(depth + 1)
.map_or(self.temps.len(), |next| next.temps_base);
Some(start..end)
}
pub fn temps(&self, depth: usize) -> &[Value] {
self.temp_range(depth)
.and_then(|r| self.temps.get(r))
.unwrap_or(&[])
}
pub fn temp(&self, depth: usize, slot: usize) -> Option<&Value> {
self.temps(depth).get(slot)
}
#[must_use]
pub fn is_temp_written(&self, depth: usize, slot: usize) -> bool {
self.temp_range(depth)
.and_then(|r| self.temps_written.get(r))
.and_then(|written| written.get(slot))
.copied()
.unwrap_or(false)
}
fn ensure_temp(&mut self, depth: usize, slot: usize) -> Option<usize> {
let range = self.temp_range(depth)?;
if slot < range.len() {
return Some(range.start + slot);
}
let grow = slot + 1 - range.len();
if depth + 1 == self.frames.len() {
self.temps.resize(range.end + grow, Value::Null);
self.temps_written.resize(range.end + grow, false);
} else {
self.temps.splice(
range.end..range.end,
core::iter::repeat_n(Value::Null, grow),
);
self.temps_written
.splice(range.end..range.end, core::iter::repeat_n(false, grow));
for frame in &mut self.frames[depth + 1..] {
frame.temps_base += grow;
}
}
Some(range.start + slot)
}
pub fn write_temp(&mut self, depth: usize, slot: usize, val: Value) {
if let Some(i) = self.ensure_temp(depth, slot) {
self.temps[i] = val;
self.temps_written[i] = true;
}
}
pub fn take_temp(&mut self, depth: usize, slot: usize) -> Value {
match self.ensure_temp(depth, slot) {
Some(i) => core::mem::replace(&mut self.temps[i], Value::Null),
None => Value::Null,
}
}
pub fn take_top_temps(&mut self) -> Vec<Value> {
let Some(base) = self.frames.last().map(|f| f.temps_base) else {
return Vec::new();
};
self.temps_written.truncate(base);
self.temps.drain(base..).collect()
}
#[cfg(test)]
pub fn clear_temp_written(&mut self, depth: usize, slot: usize) {
if let Some(r) = self.temp_range(depth)
&& let Some(bit) = self.temps_written.get_mut(r.start + slot)
{
*bit = false;
}
}
fn container_range(&self, depth: usize) -> Option<Range<usize>> {
let start = self.frames.get(depth)?.containers_base;
let end = self
.frames
.get(depth + 1)
.map_or(self.containers.len(), |next| next.containers_base);
Some(start..end)
}
pub fn containers(&self, depth: usize) -> &[ContainerPosition] {
self.container_range(depth)
.and_then(|r| self.containers.get(r))
.unwrap_or(&[])
}
pub fn top_container(&self) -> Option<ContainerPosition> {
let base = self.frames.last()?.containers_base;
(self.containers.len() > base).then(|| self.containers[self.containers.len() - 1])
}
pub fn top_container_mut(&mut self) -> Option<&mut ContainerPosition> {
let base = self.frames.last()?.containers_base;
(self.containers.len() > base).then(|| {
let last = self.containers.len() - 1;
&mut self.containers[last]
})
}
pub fn top_containers(&self) -> &[ContainerPosition] {
self.frames
.last()
.and_then(|f| self.containers.get(f.containers_base..))
.unwrap_or(&[])
}
pub fn push_container(&mut self, pos: ContainerPosition) {
if !self.frames.is_empty() {
self.containers.push(pos);
}
}
pub fn pop_container(&mut self) -> Option<ContainerPosition> {
let base = self.frames.last()?.containers_base;
(self.containers.len() > base)
.then(|| self.containers.pop())
.flatten()
}
pub fn reset_top_containers(&mut self, pos: ContainerPosition) {
if let Some(base) = self.frames.last().map(|f| f.containers_base) {
self.containers.truncate(base);
self.containers.push(pos);
}
}
pub fn unwind_top_containers(&mut self, keep: usize, offset: usize) {
if let Some(base) = self.frames.last().map(|f| f.containers_base) {
self.containers.truncate(base + keep);
if let Some(top) = self.containers.get_mut(base..).and_then(<[_]>::last_mut) {
top.offset = offset;
}
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Thread {
pub call_stack: CallStack,
pub base_depth: usize,
}
#[derive(Debug, Clone)]
pub(crate) enum ChoiceDisplay {
Text(String),
Fragment(u32),
}
#[derive(Debug, Clone)]
pub(crate) struct PendingChoice {
pub display: ChoiceDisplay,
pub target_id: DefinitionId,
pub target_idx: u32,
pub target_offset: usize,
pub flags: ChoiceFlags,
#[expect(
dead_code,
reason = "needs research — likely needed for structured output / voice acting"
)]
pub original_index: usize,
pub tags: Vec<String>,
pub thread_fork: Thread,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExecMode {
#[default]
Dev,
Prod,
}
#[derive(Debug, Clone)]
#[expect(
clippy::struct_excessive_bools,
reason = "VM flags are inherently boolean"
)]
pub(crate) struct Flow {
pub threads: Vec<Thread>,
pub value_stack: Vec<Value>,
pub output: OutputBuffer,
pub pending_choices: Vec<PendingChoice>,
pub current_tags: Vec<String>,
pub in_tag: bool,
pub skipping_choice: bool,
pub did_safe_exit: bool,
pub did_unsafe_yield: bool,
pub line_delivered_this_turn: bool,
pub ran_out_of_content_cause: RanOutOfContentCause,
pub exec_mode: ExecMode,
pub pure_callback: PureCallbackState,
pub next_block_id: u64,
pub pending_terminal: PendingTerminal,
pub warnings: Vec<crate::error::RuntimeWarning>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PendingTerminal(Option<(u64, super::types::Step)>);
impl PendingTerminal {
pub(crate) fn stash(&mut self, block_id: u64, terminal: super::types::Step) {
self.0 = Some((block_id, terminal));
}
pub(crate) fn take_if_current(&mut self, current_block_id: u64) -> Option<super::types::Step> {
self.0
.take()
.and_then(|(stamp, terminal)| (stamp == current_block_id).then_some(terminal))
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct PureCallbackState {
pub depth: u16,
pub verb: &'static str,
pub effectful: bool,
}
impl Flow {
pub fn warn(&mut self, warning: crate::error::RuntimeWarning) {
if self.warnings.len() < crate::RUNTIME_WARNING_CAP {
self.warnings.push(warning);
}
}
#[expect(clippy::expect_used)]
pub fn current_thread(&self) -> &Thread {
self.threads
.last()
.expect("flow must always have at least one thread")
}
#[expect(clippy::expect_used)]
pub fn current_thread_mut(&mut self) -> &mut Thread {
self.threads
.last_mut()
.expect("flow must always have at least one thread")
}
pub fn can_pop_thread(&self) -> bool {
self.threads.len() > 1
}
pub fn has_eval_boundary(&self) -> bool {
let cs = &self.current_thread().call_stack;
(0..cs.len())
.filter_map(|i| cs.get(i))
.any(|f| f.frame_type == CallFrameType::FunctionEvalFromGame)
}
pub fn pop_thread(&mut self) {
self.threads.pop();
}
pub fn fork_thread(&mut self) -> Thread {
Thread {
call_stack: self.current_thread().call_stack.clone(),
base_depth: 0,
}
}
pub fn at_thread_base(&self) -> bool {
let thread = self.current_thread();
self.can_pop_thread() && thread.call_stack.len() <= thread.base_depth
}
pub fn external_args(&self) -> &[Value] {
let stack = &self.current_thread().call_stack;
match stack.last() {
Some(f) if f.frame_type == CallFrameType::External => stack.temps(stack.len() - 1),
_ => &[],
}
}
pub fn external_fn_id(&self) -> Option<DefinitionId> {
let frame = self.current_thread().call_stack.last()?;
if frame.frame_type == CallFrameType::External {
frame.external_fn_id
} else {
None
}
}
pub fn resolve_external(&mut self, value: Value) {
let thread = self.current_thread_mut();
if let Some(frame) = thread.call_stack.last()
&& frame.frame_type == CallFrameType::External
{
let ret_addr = frame.return_address;
thread.call_stack.pop();
self.value_stack.push(value);
if let Some(pos) = ret_addr
&& let Some(top) = self.current_thread_mut().call_stack.top_container_mut()
{
*top = pos;
}
}
}
pub fn invoke_fallback(&mut self, container_idx: u32, param_slots: &[u16]) {
let output_start = self.output.mark();
let Some(thread) = self.threads.last_mut() else {
return;
};
let stack = &mut thread.call_stack;
if stack
.last()
.is_some_and(|frame| frame.frame_type == CallFrameType::External)
{
let args = stack.take_top_temps();
if let Some(frame) = stack.last_mut() {
frame.frame_type = CallFrameType::Function;
frame.external_fn_id = None;
frame.function_output_start = Some(output_start);
}
stack.reset_top_containers(ContainerPosition {
container_idx,
offset: 0,
});
let depth = stack.top_depth();
self.value_stack.extend(args);
let last = self.threads.len() - 1;
if let Some(depth) = depth {
let stack = &mut self.threads[last].call_stack;
for slot in param_slots.iter().rev() {
let Some(val) = self.value_stack.pop() else {
break;
};
stack.write_temp(depth, usize::from(*slot), val);
}
}
}
}
pub fn pop_value(&mut self) -> Result<Value, RuntimeError> {
self.value_stack
.pop()
.ok_or_else(|| RuntimeError::StackUnderflow)
}
pub fn peek_value(&self) -> Result<&Value, RuntimeError> {
self.value_stack
.last()
.ok_or_else(|| RuntimeError::StackUnderflow)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::story::Step;
fn pos(container_idx: u32, offset: usize) -> ContainerPosition {
ContainerPosition {
container_idx,
offset,
}
}
fn function_frame() -> CallFrame {
CallFrame::new(CallFrameType::Function, Some(pos(0, 7)), None)
}
#[test]
fn frames_see_only_their_own_temp_segments() {
let mut stack = CallStack::new(
CallFrame::new(CallFrameType::Root, None, None),
Some(pos(0, 0)),
);
stack.write_temp(0, 1, Value::Int(10));
stack.push(function_frame(), Some(pos(1, 0)));
stack.write_temp(1, 0, Value::Int(20));
assert_eq!(stack.temps(0), &[Value::Null, Value::Int(10)]);
assert_eq!(stack.temps(1), &[Value::Int(20)]);
assert!(!stack.is_temp_written(0, 0), "padding is not a write");
assert!(stack.is_temp_written(0, 1));
assert!(stack.is_temp_written(1, 0));
assert!(
!stack.is_temp_written(1, 1),
"past the segment's end is unwritten"
);
assert_eq!(stack.temp(1, 1), None);
assert_eq!(
stack.temps(2),
&[],
"a frame that does not exist has no slots"
);
}
#[test]
fn pop_releases_exactly_the_top_frame_storage() {
let mut stack = CallStack::new(
CallFrame::new(CallFrameType::Root, None, None),
Some(pos(0, 0)),
);
stack.write_temp(0, 0, Value::Int(1));
stack.push(function_frame(), Some(pos(1, 0)));
stack.write_temp(1, 3, Value::Int(2));
stack.push_container(pos(2, 5));
let popped = stack.pop().expect("a frame to pop");
assert_eq!(popped.frame_type, CallFrameType::Function);
assert_eq!(popped.return_address, Some(pos(0, 7)));
assert_eq!(stack.len(), 1);
assert_eq!(stack.temps(0), &[Value::Int(1)]);
assert_eq!(stack.top_containers(), &[pos(0, 0)]);
assert_eq!(stack.top_container(), Some(pos(0, 0)));
}
#[test]
fn growing_a_lower_frame_shifts_the_frames_above_it() {
let mut stack = CallStack::new(
CallFrame::new(CallFrameType::Root, None, None),
Some(pos(0, 0)),
);
stack.write_temp(0, 0, Value::Int(1));
stack.push(function_frame(), Some(pos(1, 0)));
stack.write_temp(1, 0, Value::Int(100));
stack.write_temp(1, 1, Value::Int(101));
stack.push(function_frame(), Some(pos(2, 0)));
stack.write_temp(2, 0, Value::Int(200));
stack.write_temp(0, 3, Value::Int(4));
assert_eq!(
stack.temps(0),
&[Value::Int(1), Value::Null, Value::Null, Value::Int(4)]
);
assert_eq!(stack.temps(1), &[Value::Int(100), Value::Int(101)]);
assert_eq!(stack.temps(2), &[Value::Int(200)]);
assert!(stack.is_temp_written(0, 3));
assert!(!stack.is_temp_written(0, 2));
assert!(stack.is_temp_written(1, 1));
assert!(stack.is_temp_written(2, 0));
assert_eq!(stack.take_temp(1, 4), Value::Null);
assert_eq!(stack.temps(1).len(), 5);
assert_eq!(stack.temps(2), &[Value::Int(200)]);
assert_eq!(stack.take_temp(2, 0), Value::Int(200));
assert_eq!(stack.temps(2), &[Value::Null]);
assert!(
stack.is_temp_written(2, 0),
"a take leaves the written bit alone"
);
}
#[test]
fn external_frame_args_are_written_by_construction_and_movable() {
let mut stack = CallStack::new(
CallFrame::new(CallFrameType::Root, None, None),
Some(pos(0, 0)),
);
stack.write_temp(0, 0, Value::Int(1));
stack.push_with_args(
CallFrame::external(
DefinitionId::new(brink_format::DefinitionTag::Address, 9),
Some(pos(0, 3)),
),
vec![Value::Int(7), Value::Bool(true)],
);
assert_eq!(stack.temps(1), &[Value::Int(7), Value::Bool(true)]);
assert!(stack.is_temp_written(1, 1));
assert_eq!(
stack.top_container(),
None,
"an external frame executes nowhere"
);
let args = stack.take_top_temps();
assert_eq!(args, vec![Value::Int(7), Value::Bool(true)]);
assert_eq!(stack.temps(1), &[]);
assert_eq!(
stack.temps(0),
&[Value::Int(1)],
"the caller's slots are untouched"
);
}
#[test]
fn container_operations_never_reach_the_frame_below() {
let mut stack = CallStack::new(
CallFrame::new(CallFrameType::Root, None, None),
Some(pos(0, 0)),
);
stack.push_container(pos(1, 0));
stack.push(function_frame(), None);
assert_eq!(stack.top_container(), None);
assert_eq!(
stack.pop_container(),
None,
"nothing to pop in an empty segment"
);
assert_eq!(stack.containers(0), &[pos(0, 0), pos(1, 0)]);
stack.push_container(pos(5, 0));
stack.push_container(pos(6, 2));
assert_eq!(stack.top_containers(), &[pos(5, 0), pos(6, 2)]);
stack.unwind_top_containers(1, 9);
assert_eq!(stack.top_containers(), &[pos(5, 9)]);
stack.reset_top_containers(pos(8, 1));
assert_eq!(stack.top_containers(), &[pos(8, 1)]);
if let Some(top) = stack.top_container_mut() {
top.offset = 4;
}
assert_eq!(stack.top_container(), Some(pos(8, 4)));
assert_eq!(stack.containers(0), &[pos(0, 0), pos(1, 0)]);
stack.pop();
assert_eq!(stack.top_container(), Some(pos(1, 0)));
}
#[test]
fn a_fork_is_an_independent_copy() {
let mut stack = CallStack::new(
CallFrame::new(CallFrameType::Root, None, None),
Some(pos(0, 0)),
);
stack.push(function_frame(), Some(pos(1, 0)));
stack.write_temp(1, 0, Value::Int(1));
let mut fork = stack.clone();
fork.write_temp(1, 0, Value::Int(2));
fork.push(function_frame(), Some(pos(2, 0)));
assert_eq!(stack.temps(1), &[Value::Int(1)]);
assert_eq!(stack.len(), 2);
assert_eq!(fork.temps(1), &[Value::Int(2)]);
assert_eq!(fork.len(), 3);
}
#[test]
fn take_if_current_returns_a_fresh_stash() {
let mut pending = PendingTerminal::default();
pending.stash(3, Step::Done);
assert_eq!(pending.take_if_current(3), Some(Step::Done));
}
#[test]
fn take_if_current_discards_a_stash_stamped_for_an_earlier_block() {
let mut pending = PendingTerminal::default();
pending.stash(3, Step::Done);
assert_eq!(
pending.take_if_current(4),
None,
"a stash stamped for block 3 must not surface once the current \
block has moved to 4"
);
}
#[test]
fn take_if_current_always_empties_the_slot_even_when_stale() {
let mut pending = PendingTerminal::default();
pending.stash(3, Step::Done);
assert_eq!(pending.take_if_current(4), None, "first (stale) read");
assert_eq!(
pending.take_if_current(3),
None,
"the slot was already emptied by the stale read above — it must \
not resurrect the old value just because the stamp is asked \
for again"
);
}
#[test]
fn take_if_current_on_an_empty_slot_is_always_none() {
let mut pending = PendingTerminal::default();
assert_eq!(pending.take_if_current(0), None);
}
#[test]
fn classify_tunnel_with_can_pop_is_tunnel() {
assert_eq!(
classify_ran_out_of_content(CallFrameType::Tunnel, true),
RanOutOfContentCause::Tunnel
);
}
#[test]
fn classify_function_with_can_pop_is_function() {
assert_eq!(
classify_ran_out_of_content(CallFrameType::Function, true),
RanOutOfContentCause::Function
);
}
#[test]
fn classify_other_frame_types_with_can_pop_is_unknown() {
for frame_type in [
CallFrameType::Root,
CallFrameType::External,
CallFrameType::FunctionEvalFromGame,
] {
assert_eq!(
classify_ran_out_of_content(frame_type, true),
RanOutOfContentCause::Unknown,
"frame type {frame_type:?} with can_pop=true should classify as Unknown"
);
}
}
#[test]
fn classify_cannot_pop_is_always_plain() {
for frame_type in [
CallFrameType::Root,
CallFrameType::Function,
CallFrameType::Tunnel,
CallFrameType::External,
CallFrameType::FunctionEvalFromGame,
] {
assert_eq!(
classify_ran_out_of_content(frame_type, false),
RanOutOfContentCause::Plain,
"frame type {frame_type:?} with can_pop=false should classify as Plain"
);
}
}
}