mod chunk;
mod container;
mod content;
mod debug_info;
mod expr;
pub use chunk::{ContainerChunk, NameRef, Relocation, UNRESOLVED_NAME_ID};
pub use debug_info::EmitOptions;
use std::collections::HashMap;
use brink_format::{
AddressDef, AddressPath, ContainerDef, DefinitionId, ExternalFnDef, GlobalVarDef, LineContent,
LineEntry, ListDef, ListItemDef, ListValue, MapKey, NameId, Opcode, OrderedMap, ScopeLineTable,
ShapeId, StoryData, StructShapeDef, Value,
};
use brink_ir::lir;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodegenError {
message: String,
}
impl CodegenError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for CodegenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CodegenError {}
fn collapse_whitespace_in_part(part: brink_format::LinePart) -> brink_format::LinePart {
match part {
brink_format::LinePart::Literal(s) => {
brink_format::LinePart::Literal(collapse_whitespace(&s))
}
brink_format::LinePart::Span {
name,
attrs,
children,
} => brink_format::LinePart::Span {
name,
attrs,
children: children
.into_iter()
.map(collapse_whitespace_in_part)
.collect(),
},
other @ (brink_format::LinePart::Slot(_) | brink_format::LinePart::Select { .. }) => other,
}
}
fn collapse_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_ws = false;
for c in s.chars() {
if c == ' ' || c == '\t' {
if !prev_ws {
out.push(' ');
}
prev_ws = true;
} else {
prev_ws = false;
out.push(c);
}
}
out
}
pub fn emit(program: &lir::Program) -> Result<StoryData, CodegenError> {
emit_with_options(program, EmitOptions::default())
}
pub fn emit_with_options(
program: &lir::Program,
options: EmitOptions<'_>,
) -> Result<StoryData, CodegenError> {
let mut state = EmitState {
chunks: Vec::new(),
addresses: Vec::new(),
definition_id_first_seen: HashMap::new(),
address_paths: Vec::new(),
scope_line_tables: HashMap::new(),
scope_line_index: HashMap::new(),
line_variant_groups: Vec::new(),
list_literals: Vec::new(),
literal_pool: Vec::new(),
name_table: program.name_table.clone(),
name_index: HashMap::new(),
errors: Vec::new(),
debug: options
.emit_debug_info
.then(debug_info::DebugCollector::new),
};
for (i, name) in state.name_table.iter().enumerate() {
#[expect(clippy::cast_possible_truncation)]
state.name_index.insert(name.clone(), NameId(i as u16));
}
walk_container(&program.root, "", "", program.root.id, &mut state);
let debug_info = state
.debug
.take()
.map(|d| d.finish(program, options.debug_sources, &mut state.errors));
if let Some(first) = core::mem::take(&mut state.errors).into_iter().next() {
return Err(first);
}
let variables = build_globals(&program.globals, &mut state);
let list_defs = build_list_defs(&program.lists);
let list_items = build_list_items(&program.list_items);
let externals = build_externals(&program.externals);
let struct_shapes = build_struct_shapes(&program.struct_shapes);
let mut line_variant_groups = state.line_variant_groups;
line_variant_groups.sort_by_key(|g| (g.scope_id.to_raw(), g.base));
let mut line_tables: Vec<ScopeLineTable> = state
.scope_line_tables
.into_iter()
.map(|(scope_id, lines)| ScopeLineTable { scope_id, lines })
.collect();
line_tables.sort_by_key(|lt| lt.scope_id.to_raw());
let name_index = &state.name_index;
let containers = state
.chunks
.into_iter()
.map(|c| c.link(|s| name_index.get(s).copied()))
.collect::<Result<Vec<_>, _>>()?;
Ok(StoryData {
containers,
line_tables,
variables,
list_defs,
list_items,
externals,
addresses: state.addresses,
address_paths: state.address_paths,
name_table: state.name_table,
list_literals: state.list_literals,
literal_pool: state.literal_pool,
struct_shapes,
private_defs: program.private_defs.clone(),
alias_table: program.aliases.clone(),
effect_rows: Vec::new(),
frame_shapes: Vec::new(),
debug_info,
line_variant_groups,
source_checksum: 0,
})
}
fn build_struct_shapes(shapes: &[lir::StructShapeDef]) -> Vec<StructShapeDef> {
shapes
.iter()
.map(|s| StructShapeDef {
id: ShapeId(s.id),
name: s.name,
fields: s.fields.clone(),
})
.collect()
}
struct EmitState {
chunks: Vec<ContainerChunk>,
addresses: Vec<AddressDef>,
definition_id_first_seen: HashMap<DefinitionId, String>,
address_paths: Vec<AddressPath>,
scope_line_tables: HashMap<DefinitionId, Vec<LineEntry>>,
scope_line_index: HashMap<DefinitionId, HashMap<LineKey, u16>>,
line_variant_groups: Vec<brink_format::LineVariantGroup>,
list_literals: Vec<ListValue>,
literal_pool: Vec<Value>,
name_table: Vec<String>,
name_index: HashMap<String, NameId>,
errors: Vec<CodegenError>,
debug: Option<debug_info::DebugCollector>,
}
fn intern_into(
name_table: &mut Vec<String>,
name_index: &mut HashMap<String, NameId>,
s: &str,
) -> NameId {
if let Some(&id) = name_index.get(s) {
return id;
}
#[expect(clippy::cast_possible_truncation)]
let id = NameId(name_table.len() as u16);
name_table.push(s.to_string());
name_index.insert(s.to_string(), id);
id
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct LineKey {
content: LineContent,
slot_info: Vec<brink_format::SlotInfo>,
}
struct ContainerEmitter<'a> {
bytecode: Vec<u8>,
scope_line_table: &'a mut Vec<LineEntry>,
scope_line_index: &'a mut HashMap<LineKey, u16>,
dedup_suspended: bool,
scope_id: DefinitionId,
line_variant_groups: &'a mut Vec<brink_format::LineVariantGroup>,
list_literals: &'a mut Vec<ListValue>,
literal_pool: &'a mut Vec<Value>,
state_name_table: &'a mut Vec<String>,
state_name_index: &'a mut HashMap<String, NameId>,
in_conditional_branch: bool,
in_choice_display: bool,
loop_stack: Vec<LoopCtx>,
errors: &'a mut Vec<CodegenError>,
relocations: Vec<Relocation>,
debug_entries: Option<Vec<debug_info::RawDebugEntry>>,
}
struct LoopCtx {
break_patches: Vec<usize>,
continue_patches: Vec<usize>,
}
impl<'a> ContainerEmitter<'a> {
fn new(state: &'a mut EmitState, scope_id: DefinitionId) -> Self {
let scope_line_table = state.scope_line_tables.entry(scope_id).or_default();
let scope_line_index = state.scope_line_index.entry(scope_id).or_default();
Self {
bytecode: Vec::new(),
scope_line_table,
scope_line_index,
dedup_suspended: false,
scope_id,
line_variant_groups: &mut state.line_variant_groups,
list_literals: &mut state.list_literals,
literal_pool: &mut state.literal_pool,
state_name_table: &mut state.name_table,
state_name_index: &mut state.name_index,
in_conditional_branch: false,
in_choice_display: false,
loop_stack: Vec::new(),
errors: &mut state.errors,
relocations: Vec::new(),
debug_entries: None,
}
}
fn emit_push_string(&mut self, text: &str) {
self.intern_string(text);
self.emit(Opcode::PushString(UNRESOLVED_NAME_ID));
#[expect(clippy::cast_possible_truncation)]
let offset = (self.bytecode.len() - 2) as u32;
self.relocations.push(Relocation {
offset,
name: NameRef::Symbol(text.to_string()),
});
}
#[expect(clippy::needless_pass_by_value)]
fn emit(&mut self, op: Opcode) {
op.encode(&mut self.bytecode);
}
fn add_line(
&mut self,
text: &str,
source_location: Option<brink_format::SourceLocation>,
) -> u16 {
self.add_line_with_hash(
text,
brink_format::content_hash(text),
Vec::new(),
source_location,
)
}
fn add_line_with_hash(
&mut self,
text: &str,
source_hash: u64,
slot_info: Vec<brink_format::SlotInfo>,
source_location: Option<brink_format::SourceLocation>,
) -> u16 {
let text = if self.in_choice_display {
text.to_owned()
} else {
collapse_whitespace(text)
};
self.push_line(
LineContent::Plain(text),
source_hash,
slot_info,
source_location,
)
}
fn add_template_line(
&mut self,
parts: brink_format::LineTemplate,
source_hash: u64,
slot_info: Vec<brink_format::SlotInfo>,
source_location: Option<brink_format::SourceLocation>,
) -> u16 {
let parts = if self.in_choice_display {
parts
} else {
parts.into_iter().map(collapse_whitespace_in_part).collect()
};
self.push_line(
LineContent::Template(parts),
source_hash,
slot_info,
source_location,
)
}
#[expect(clippy::cast_possible_truncation)]
fn push_line(
&mut self,
content: LineContent,
source_hash: u64,
slot_info: Vec<brink_format::SlotInfo>,
source_location: Option<brink_format::SourceLocation>,
) -> u16 {
let key = LineKey { content, slot_info };
if !self.dedup_suspended
&& let Some(&idx) = self.scope_line_index.get(&key)
{
return idx;
}
let idx = self.scope_line_table.len() as u16;
let flags = brink_format::LineFlags::from_content(&key.content);
self.scope_line_table.push(LineEntry {
content: key.content.clone(),
flags,
source_hash,
audio_ref: None,
slot_info: key.slot_info.clone(),
source_location,
});
if !self.dedup_suspended {
self.scope_line_index.insert(key, idx);
}
idx
}
fn intern_string(&mut self, s: &str) -> NameId {
if let Some(&id) = self.state_name_index.get(s) {
return id;
}
#[expect(clippy::cast_possible_truncation)]
let id = NameId(self.state_name_table.len() as u16);
self.state_name_table.push(s.to_string());
self.state_name_index.insert(s.to_string(), id);
id
}
#[expect(clippy::needless_pass_by_value)]
fn emit_jump_placeholder(&mut self, op: Opcode) -> usize {
op.encode(&mut self.bytecode);
self.bytecode.len() - 4
}
fn patch_jump(&mut self, offset_pos: usize) {
let target = self.bytecode.len();
let instruction_end = offset_pos + 4;
#[expect(clippy::cast_possible_wrap)]
#[expect(clippy::cast_possible_truncation)]
let relative = (target - instruction_end) as i32;
let bytes = relative.to_le_bytes();
self.bytecode[offset_pos..offset_pos + 4].copy_from_slice(&bytes);
}
fn record_debug_entry(&mut self, stmt: &lir::Stmt, prologue_end: bool) {
if self.debug_entries.is_none() {
return;
}
#[expect(clippy::cast_possible_truncation)]
let offset = self.bytecode.len() as u32;
if let Some(entries) = self.debug_entries.as_mut() {
entries.push(debug_info::RawDebugEntry {
offset,
provenance: stmt.provenance,
prologue_end,
});
}
}
}
fn is_scope_kind(kind: lir::ContainerKind) -> bool {
matches!(
kind,
lir::ContainerKind::Root | lir::ContainerKind::Knot | lir::ContainerKind::Stitch
)
}
#[expect(
clippy::too_many_lines,
reason = "single linear emit sequence; splitting would obscure the order"
)]
fn walk_container(
container: &lir::Container,
path: &str,
scope_author_path: &str,
scope_id: DefinitionId,
state: &mut EmitState,
) {
if let Some(prior_path) = state
.definition_id_first_seen
.insert(container.id, path.to_string())
{
state.errors.push(CodegenError::new(format!(
"duplicate DefinitionId {} assigned to two different containers, at paths {prior_path:?} and {path:?} — every container must have a unique DefinitionId (#1673); this collision would otherwise reach the runtime silently and produce wrong player-visible output, as it did in #1504",
container.id
)));
}
let this_scope_path = if is_scope_kind(container.kind) {
path
} else {
scope_author_path
};
let debug_enabled = state.debug.is_some();
let raw_entries: Vec<debug_info::RawDebugEntry> = Vec::new();
let leading_choice_output = matches!(
container.body.first().map(|stmt| &stmt.kind),
Some(lir::StmtKind::ChoiceOutput { .. })
);
let prologue_end_index = if leading_choice_output {
(container.body.len() > 1).then_some(1)
} else {
(!container.body.is_empty()).then_some(0)
};
let mut raw_locals: Vec<debug_info::RawLocal> = Vec::new();
if debug_enabled {
for param in &container.params {
raw_locals.push(debug_info::RawLocal {
slot: param.slot,
name: param.name,
declaring_range: None,
synthetic: false,
});
}
for stmt in &container.body {
if let lir::StmtKind::DeclareTemp {
slot,
name,
synthetic,
..
} = &stmt.kind
{
raw_locals.push(debug_info::RawLocal {
slot: *slot,
name: *name,
declaring_range: Some(stmt.provenance),
synthetic: *synthetic,
});
}
}
}
let mut emitter = ContainerEmitter::new(state, scope_id);
if debug_enabled {
emitter.debug_entries = Some(raw_entries);
}
if container.kind == lir::ContainerKind::ConditionalBranch
|| container.kind == lir::ContainerKind::SequenceBranch
{
emitter.in_conditional_branch = true;
}
emitter.emit_body_top_level(&container.body, prologue_end_index);
let raw_entries = if debug_enabled {
let mut entries = emitter.debug_entries.take().unwrap_or_default();
if prologue_end_index.is_none() {
#[expect(clippy::cast_possible_truncation)]
entries.push(debug_info::RawDebugEntry {
offset: emitter.bytecode.len() as u32,
provenance: container.provenance,
prologue_end: true,
});
}
entries
} else {
Vec::new()
};
let path_hash: i32 = path.chars().map(|c| c as i32).sum();
let name = if is_scope_kind(container.kind) {
Some(emitter.intern_string(path))
} else {
None
};
let address_path_id: Option<NameId> = if is_scope_kind(container.kind) {
name
} else if container.labeled {
let label = container.name.as_deref().unwrap_or("_anon");
let qualified = if this_scope_path.is_empty() {
label.to_string()
} else {
format!("{this_scope_path}.{label}")
};
Some(emitter.intern_string(&qualified))
} else {
None
};
let relocations = core::mem::take(&mut emitter.relocations);
let def = ContainerDef {
id: container.id,
scope_id,
name,
bytecode: emitter.bytecode,
counting_flags: container.counting_flags,
path_hash,
param_count: u8::try_from(container.params.len()).unwrap_or(u8::MAX),
params: container
.params
.iter()
.map(|p| brink_format::ParamMeta {
slot: p.slot,
name: p.name,
is_ref: p.is_ref,
})
.collect(),
local: container.local,
};
state.chunks.push(ContainerChunk { def, relocations });
if let Some(debug) = state.debug.as_mut() {
debug.push_container(raw_entries, raw_locals);
}
state.addresses.push(AddressDef {
id: container.id,
container_id: container.id,
byte_offset: 0,
});
if let Some(path_id) = address_path_id {
state.address_paths.push(AddressPath {
path: path_id,
target: container.id,
});
}
for child in &container.children {
let child_name = child.name.as_deref().unwrap_or("_anon");
let needs_stitch_prefix = container.kind == lir::ContainerKind::Knot
&& !container.is_function
&& child.kind != lir::ContainerKind::Stitch;
let segment = if needs_stitch_prefix && child.kind == lir::ContainerKind::Sequence {
let n = child_name.strip_prefix("s-").unwrap_or(child_name);
format!("0.{n}")
} else if needs_stitch_prefix {
format!("0.{child_name}")
} else if child.kind == lir::ContainerKind::Sequence {
child_name
.strip_prefix("s-")
.unwrap_or(child_name)
.to_string()
} else if container.kind == lir::ContainerKind::Sequence
&& child.kind == lir::ContainerKind::SequenceBranch
{
format!("s{child_name}")
} else {
child_name.to_string()
};
let child_path = if path.is_empty() {
segment
} else {
format!("{path}.{segment}")
};
let child_scope_id = if is_scope_kind(child.kind) {
child.id
} else {
scope_id
};
let child_scope_author_path: &str = if is_scope_kind(child.kind) {
&child_path
} else {
this_scope_path
};
walk_container(
child,
&child_path,
child_scope_author_path,
child_scope_id,
state,
);
}
}
fn build_globals(globals: &[lir::GlobalDef], state: &mut EmitState) -> Vec<GlobalVarDef> {
globals
.iter()
.map(|g| GlobalVarDef {
id: g.id,
name: g.name,
value_type: const_value_type(&g.default),
default_value: const_to_value(&g.default, &mut state.name_table, &mut state.name_index),
mutable: g.mutable,
local: g.local,
})
.collect()
}
fn build_list_defs(lists: &[lir::ListDef]) -> Vec<ListDef> {
lists
.iter()
.map(|l| ListDef {
id: l.id,
name: l.name,
items: l.items.clone(),
})
.collect()
}
fn build_list_items(items: &[lir::ListItemDef]) -> Vec<ListItemDef> {
items
.iter()
.map(|i| ListItemDef {
id: i.id,
origin: i.origin,
ordinal: i.ordinal,
name: i.name,
})
.collect()
}
fn build_externals(externals: &[lir::ExternalDef]) -> Vec<ExternalFnDef> {
externals
.iter()
.map(|e| ExternalFnDef {
id: e.id,
name: e.name,
arg_count: e.arg_count,
fallback: e.fallback,
})
.collect()
}
fn const_value_type(v: &lir::ConstValue) -> brink_format::ValueType {
match v {
lir::ConstValue::Int(_) => brink_format::ValueType::Int,
lir::ConstValue::Float(_) => brink_format::ValueType::Float,
lir::ConstValue::Bool(_) => brink_format::ValueType::Bool,
lir::ConstValue::String(_) => brink_format::ValueType::String,
lir::ConstValue::List { .. } => brink_format::ValueType::List,
lir::ConstValue::DivertTarget(_) => brink_format::ValueType::DivertTarget,
lir::ConstValue::Null => brink_format::ValueType::Null,
lir::ConstValue::Array(_) => brink_format::ValueType::Array,
lir::ConstValue::Map(_) => brink_format::ValueType::Map,
lir::ConstValue::Record { .. } => brink_format::ValueType::Record,
lir::ConstValue::FnRef(_) => brink_format::ValueType::FnRef,
lir::ConstValue::Closure { .. } => brink_format::ValueType::Closure,
}
}
fn const_to_value(
v: &lir::ConstValue,
name_table: &mut Vec<String>,
name_index: &mut HashMap<String, NameId>,
) -> Value {
match v {
lir::ConstValue::Int(n) => Value::Int(*n),
lir::ConstValue::Float(f) => Value::Float(*f),
lir::ConstValue::Bool(b) => Value::Bool(*b),
lir::ConstValue::String(s) => Value::String(s.clone().into()),
lir::ConstValue::Null => Value::Null,
lir::ConstValue::DivertTarget(id) => Value::DivertTarget(*id),
lir::ConstValue::List { items, origins } => Value::List(
ListValue {
items: items.clone(),
origins: origins.clone(),
}
.into(),
),
lir::ConstValue::Array(items) => Value::array(
items
.iter()
.map(|i| const_to_value(i, name_table, name_index))
.collect(),
),
lir::ConstValue::Map(entries) => {
let mut map = OrderedMap::with_capacity(entries.len());
for (k, v) in entries {
let val = const_to_value(v, name_table, name_index);
map.insert(const_map_key_to_value(k), val);
}
Value::map(map)
}
lir::ConstValue::Record { shape_id, fields } => Value::record(
brink_format::ShapeId(*shape_id),
fields
.iter()
.map(|f| const_to_value(f, name_table, name_index))
.collect(),
),
lir::ConstValue::FnRef(target) => Value::FnRef(*target),
lir::ConstValue::Closure { target, env } => {
let env = env
.iter()
.map(|e| match e {
lir::ConstClosureEntry::Val { name, value } => {
let payload = const_to_value(value, name_table, name_index);
brink_format::ClosureEnvEntry {
name: intern_into(name_table, name_index, name),
is_ref: false,
payload,
}
}
lir::ConstClosureEntry::Ref { name, cell } => brink_format::ClosureEnvEntry {
name: intern_into(name_table, name_index, name),
is_ref: true,
payload: Value::VariablePointer(*cell),
},
})
.collect();
Value::closure(*target, env)
}
}
}
fn const_map_key_to_value(k: &lir::ConstMapKey) -> MapKey {
match k {
lir::ConstMapKey::Int(n) => MapKey::Int(*n),
lir::ConstMapKey::Str(s) => MapKey::Str(s.clone().into()),
lir::ConstMapKey::Bool(b) => MapKey::Bool(*b),
}
}