use alloc::string::String;
use alloc::sync::Arc;
use alloc::vec;
use alloc::vec::Vec;
use brink_format::{ChoiceFlags, DefinitionId, Value};
use crate::error::{RanOutOfContentCause, RuntimeError};
use crate::output::OutputBuffer;
#[derive(Debug, Clone, Copy)]
pub(crate) struct ContainerPosition {
pub container_idx: u32,
pub offset: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CallFrameType {
Root,
Function,
Tunnel,
Thread,
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)]
pub(crate) struct CallFrame {
pub return_address: Option<ContainerPosition>,
pub temps: Vec<Value>,
pub container_stack: Vec<ContainerPosition>,
pub frame_type: CallFrameType,
pub external_fn_id: Option<DefinitionId>,
pub function_output_start: Option<usize>,
}
#[derive(Debug, Clone)]
pub(crate) struct CallStack {
inherited: Option<Arc<[CallFrame]>>,
own: Vec<CallFrame>,
cached_snapshot: Option<Arc<[CallFrame]>>,
pub(crate) materialization_count: u64,
}
impl CallStack {
pub fn new(frame: CallFrame) -> Self {
Self {
inherited: None,
own: vec![frame],
cached_snapshot: None,
materialization_count: 0,
}
}
pub fn push(&mut self, frame: CallFrame) {
self.cached_snapshot = None;
self.own.push(frame);
}
pub fn pop(&mut self) -> Option<CallFrame> {
self.cached_snapshot = None;
if let Some(f) = self.own.pop() {
return Some(f);
}
self.materialize();
self.own.pop()
}
pub fn last(&self) -> Option<&CallFrame> {
self.own
.last()
.or_else(|| self.inherited.as_ref().and_then(|h| h.last()))
}
pub fn last_mut(&mut self) -> Option<&mut CallFrame> {
if !self.own.is_empty() {
return self.own.last_mut();
}
self.materialize();
self.own.last_mut()
}
pub fn len(&self) -> usize {
self.inherited.as_ref().map_or(0, |h| h.len()) + self.own.len()
}
pub fn is_empty(&self) -> bool {
self.own.is_empty() && self.inherited.as_ref().is_none_or(|h| h.is_empty())
}
pub fn get(&self, index: usize) -> Option<&CallFrame> {
let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
if index < inherited_len {
self.inherited.as_ref().and_then(|h| h.get(index))
} else {
self.own.get(index - inherited_len)
}
}
pub fn get_mut(&mut self, index: usize) -> Option<&mut CallFrame> {
let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
if index < inherited_len {
self.materialize();
self.own.get_mut(index)
} else {
self.own.get_mut(index - inherited_len)
}
}
pub fn snapshot(&mut self) -> (Arc<[CallFrame]>, bool) {
if let Some(ref cached) = self.cached_snapshot {
return (Arc::clone(cached), true);
}
let rc = match &self.inherited {
None => Arc::from(self.own.as_slice()),
Some(prefix) if self.own.is_empty() => Arc::clone(prefix),
Some(prefix) => {
let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
combined.extend_from_slice(prefix);
combined.extend_from_slice(&self.own);
Arc::from(combined)
}
};
self.cached_snapshot = Some(Arc::clone(&rc));
(rc, false)
}
fn materialize(&mut self) -> bool {
self.cached_snapshot = None;
if let Some(prefix) = self.inherited.take() {
let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
combined.extend_from_slice(&prefix);
combined.append(&mut self.own);
self.own = combined;
self.materialization_count += 1;
true
} else {
false
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Thread {
pub call_stack: CallStack,
}
#[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 ran_out_of_content_cause: RanOutOfContentCause,
pub exec_mode: ExecMode,
pub pure_callback: PureCallbackState,
pub next_block_id: u64,
pub pending_terminal: PendingTerminal,
}
#[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 {
#[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, bool) {
let (shared, cache_hit) = self.current_thread_mut().call_stack.snapshot();
(
Thread {
call_stack: CallStack {
inherited: Some(shared),
own: Vec::new(),
cached_snapshot: None,
materialization_count: 0,
},
},
cache_hit,
)
}
pub fn drain_materializations(&mut self) -> u64 {
let mut total = 0;
for thread in &mut self.threads {
total += thread.call_stack.materialization_count;
thread.call_stack.materialization_count = 0;
}
total
}
pub fn external_args(&self) -> &[Value] {
let frame = self.current_thread().call_stack.last();
match frame {
Some(f) if f.frame_type == CallFrameType::External => &f.temps,
_ => &[],
}
}
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(f) = self.current_thread_mut().call_stack.last_mut()
&& let Some(top) = f.container_stack.last_mut()
{
*top = pos;
}
}
}
pub fn invoke_fallback(&mut self, container_idx: u32) {
let output_start = self.output.target_len();
let thread = self.current_thread_mut();
if let Some(frame) = thread.call_stack.last_mut()
&& frame.frame_type == CallFrameType::External
{
let args = core::mem::take(&mut frame.temps);
frame.frame_type = CallFrameType::Function;
frame.container_stack = vec![ContainerPosition {
container_idx,
offset: 0,
}];
frame.external_fn_id = None;
frame.function_output_start = Some(output_start);
self.value_stack.extend(args);
}
}
pub fn pop_value(&mut self) -> Result<Value, RuntimeError> {
self.value_stack.pop().ok_or(RuntimeError::StackUnderflow)
}
pub fn peek_value(&self) -> Result<&Value, RuntimeError> {
self.value_stack.last().ok_or(RuntimeError::StackUnderflow)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::story::Step;
#[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::Thread,
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::Thread,
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"
);
}
}
}