From 12a805a266923be49d151d7cad853c77e7eceb5f Mon Sep 17 00:00:00 2001 From: "Tristan Poland (Trident_For_U)" <34868944+tristanpoland@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:06:47 -0400 Subject: [PATCH 1/2] updates --- src/bytecode/mod.rs | 39 +- src/codegen/bytecode_codegen.rs | 202 ++++++---- src/codegen/mod.rs | 4 +- src/codegen/rust_codegen.rs | 339 +++++++++++----- src/compiler.rs | 71 +++- src/disk.rs | 144 +++---- src/lib.rs | 35 +- src/metadata.rs | 48 ++- src/project.rs | 40 +- src/vm/mod.rs | 100 +++-- tests/bytecode_tests.rs | 665 ++++++++++++++++++++++++-------- 11 files changed, 1139 insertions(+), 548 deletions(-) diff --git a/src/bytecode/mod.rs b/src/bytecode/mod.rs index 9181c97..6edbe4f 100644 --- a/src/bytecode/mod.rs +++ b/src/bytecode/mod.rs @@ -15,14 +15,21 @@ pub type LabelId = usize; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Instruction { /// Copy `bytes` into the arena at `offset`. Used to initialise constant inputs. - InitBytes { offset: usize, bytes: Vec }, + InitBytes { + offset: usize, + bytes: Vec, + }, /// Store a `TypeSlot { size, align }` at `offset` inside the arena. /// /// Emitted by the codegen whenever a generic node's type parameter `T` has /// been resolved at graph-compile time. The VM writes the struct into the /// arena; the dispatch function reads it via `type_slots[i]`. - InitTypeSlot { offset: usize, size: usize, align: usize }, + InitTypeSlot { + offset: usize, + size: usize, + align: usize, + }, /// Direct function call — no dispatch table, no type lookup. /// @@ -35,12 +42,12 @@ pub enum Instruction { /// `type_slot_offsets` are arena offsets of `TypeSlot` values, one per /// resolved generic type parameter. Empty for fully-concrete functions. Call { - fn_ptr: u64, - node_type: String, - input_offsets: Vec, - output_offset: usize, - has_output: bool, - type_slot_offsets: Vec, + fn_ptr: u64, + node_type: String, + input_offsets: Vec, + output_offset: usize, + has_output: bool, + type_slot_offsets: Vec, }, /// Copy bytes from one arena slot into another. @@ -60,8 +67,8 @@ pub enum Instruction { /// Branch on the bool byte at `condition_offset` (non-zero → true). JumpIf { condition_offset: usize, - true_label: LabelId, - false_label: LabelId, + true_label: LabelId, + false_label: LabelId, }, Jump(LabelId), Label(LabelId), @@ -75,22 +82,22 @@ pub enum Instruction { /// After preparation `pbgc::vm::run` takes only `&BpProgram`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BpProgram { - pub name: String, + pub name: String, /// Total byte size of the arena required to execute this program. - pub arena_size: usize, + pub arena_size: usize, /// Peak number of input-pointer arguments across all Call instructions. /// Used to pre-allocate the arg_ptrs scratch buffer in the VM. pub max_args_count: usize, - pub instructions: Vec, + pub instructions: Vec, } impl BpProgram { pub fn new(name: impl Into) -> Self { Self { - name: name.into(), - arena_size: 0, + name: name.into(), + arena_size: 0, max_args_count: 0, - instructions: Vec::new(), + instructions: Vec::new(), } } } diff --git a/src/codegen/bytecode_codegen.rs b/src/codegen/bytecode_codegen.rs index f7578be..699ad3e 100644 --- a/src/codegen/bytecode_codegen.rs +++ b/src/codegen/bytecode_codegen.rs @@ -356,16 +356,12 @@ impl<'a> BytecodeCodegen<'a> { } fn emit_custom_event_dispatch(&mut self, node: &NodeInstance) -> Result<(), GraphyError> { - let event_uid = node - .properties - .get("event_uid") - .cloned() - .ok_or_else(|| { - GraphyError::Custom(format!( - "emit_custom_event node '{}' has no event_uid property", - node.id - )) - })?; + let event_uid = node.properties.get("event_uid").cloned().ok_or_else(|| { + GraphyError::Custom(format!( + "emit_custom_event node '{}' has no event_uid property", + node.id + )) + })?; // Collect input offsets for all data pins (skip execution inputs) let data_pins: Vec<_> = node @@ -376,12 +372,8 @@ impl<'a> BytecodeCodegen<'a> { let mut input_offsets = Vec::new(); for pin in &data_pins { - let offset = self.resolve_input_offset( - &node.id, - &node.node_type, - &pin.id, - &pin.pin.name, - )?; + let offset = + self.resolve_input_offset(&node.id, &node.node_type, &pin.id, &pin.pin.name)?; input_offsets.push(offset); } @@ -441,16 +433,14 @@ impl<'a> BytecodeCodegen<'a> { fn component_ref_pin_connected(&self, node: &NodeInstance) -> bool { use graphy::analysis::DataSource; matches!( - self.data_resolver.get_input_source(&node.id, "component_ref"), + self.data_resolver + .get_input_source(&node.id, "component_ref"), Some(DataSource::Connection { .. }) | Some(DataSource::Constant(_)) ) } /// Resolve the staged offset of a node's `component_ref` operand. - fn resolve_component_ref_operand( - &mut self, - node: &NodeInstance, - ) -> Result { + fn resolve_component_ref_operand(&mut self, node: &NodeInstance) -> Result { let pin = node .inputs .iter() @@ -479,7 +469,8 @@ impl<'a> BytecodeCodegen<'a> { } let bytes = encode_targeted_name_blob(class_name, member, target); let offset = self.layout.alloc(bytes.len(), 1); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); self.comp_name_blobs.insert(key, offset); Ok(offset) } @@ -491,7 +482,11 @@ impl<'a> BytecodeCodegen<'a> { member: &str, ) -> Result<(), GraphyError> { let targeted = self.component_ref_pin_connected(node); - let target = if targeted { RefTarget::RefPin } else { RefTarget::SelfActor }; + let target = if targeted { + RefTarget::RefPin + } else { + RefTarget::SelfActor + }; let name_offset = self.stage_targeted_name_blob(class_name, member, target)?; let mut input_offsets = vec![name_offset]; if targeted { @@ -525,7 +520,8 @@ impl<'a> BytecodeCodegen<'a> { self.follow_exec_outputs(node) } - fn emit_comp_call( &mut self, + fn emit_comp_call( + &mut self, node: &NodeInstance, class_name: &str, member: &str, @@ -539,15 +535,19 @@ impl<'a> BytecodeCodegen<'a> { .filter(|p| p.id != "component_ref" && p.pin.name != "component") .count(); let targeted = self.component_ref_pin_connected(node); - let target = if targeted { RefTarget::RefPin } else { RefTarget::SelfActor }; + let target = if targeted { + RefTarget::RefPin + } else { + RefTarget::SelfActor + }; let name_key = format!("{}\0{}\0call\0{}", class_name, member, target.as_field()); let name_offset = match self.comp_name_blobs.get(&name_key) { Some(&offset) => offset, None => { - let bytes = - encode_targeted_call_name_blob(class_name, member, arg_count, target); + let bytes = encode_targeted_call_name_blob(class_name, member, arg_count, target); let offset = self.layout.alloc(bytes.len(), 1); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); self.comp_name_blobs.insert(name_key, offset); offset } @@ -556,7 +556,9 @@ impl<'a> BytecodeCodegen<'a> { if targeted { input_offsets.push(self.resolve_component_ref_operand(node)?); } - for pin in node.inputs.iter() + for pin in node + .inputs + .iter() .filter(|p| !matches!(p.pin.data_type, DataType::Exec)) .filter(|p| p.id != "component_ref" && p.pin.name != "component") { @@ -581,10 +583,13 @@ impl<'a> BytecodeCodegen<'a> { type_slot_offsets: Vec::new(), }); if has_output { - self.output_offsets.insert(node.id.clone(), OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }); + self.output_offsets.insert( + node.id.clone(), + OutputSlot { + base_offset: output_offset, + field_offsets: HashMap::new(), + }, + ); } self.record_component_ref(CompOpKind::Call, class_name, member); self.follow_exec_outputs(node) @@ -602,7 +607,11 @@ impl<'a> BytecodeCodegen<'a> { return Ok(slot.base_offset); } let targeted = self.component_ref_pin_connected(node); - let target = if targeted { RefTarget::RefPin } else { RefTarget::SelfActor }; + let target = if targeted { + RefTarget::RefPin + } else { + RefTarget::SelfActor + }; let name_offset = self.stage_targeted_name_blob(class_name, member, target)?; let mut input_offsets = vec![name_offset]; if targeted { @@ -618,10 +627,13 @@ impl<'a> BytecodeCodegen<'a> { has_output: true, type_slot_offsets: Vec::new(), }); - self.output_offsets.insert(node.id.clone(), OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }); + self.output_offsets.insert( + node.id.clone(), + OutputSlot { + base_offset: output_offset, + field_offsets: HashMap::new(), + }, + ); self.record_component_ref(CompOpKind::GetProp, class_name, member); Ok(output_offset) } @@ -641,14 +653,19 @@ impl<'a> BytecodeCodegen<'a> { return Ok(slot.base_offset); } let actor_pin = node.inputs.iter().find(|p| p.id == "actor"); - let target = if actor_pin.is_some() { RefTarget::RefPin } else { RefTarget::SelfActor }; + let target = if actor_pin.is_some() { + RefTarget::RefPin + } else { + RefTarget::SelfActor + }; let name_key = format!("ref\0{class_name}\0{member}\0{}", target.as_field()); let name_offset = match self.comp_name_blobs.get(&name_key) { Some(&offset) => offset, None => { let bytes = encode_targeted_name_blob(class_name, member, target); let offset = self.layout.alloc(bytes.len(), 1); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); self.comp_name_blobs.insert(name_key, offset); offset } @@ -667,10 +684,13 @@ impl<'a> BytecodeCodegen<'a> { has_output: true, type_slot_offsets: Vec::new(), }); - self.output_offsets.insert(node.id.clone(), OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }); + self.output_offsets.insert( + node.id.clone(), + OutputSlot { + base_offset: output_offset, + field_offsets: HashMap::new(), + }, + ); self.record_component_ref(CompOpKind::GetRef, class_name, member); Ok(output_offset) } @@ -705,10 +725,13 @@ impl<'a> BytecodeCodegen<'a> { has_output: true, type_slot_offsets: Vec::new(), }); - self.output_offsets.insert(node.id.clone(), OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }); + self.output_offsets.insert( + node.id.clone(), + OutputSlot { + base_offset: output_offset, + field_offsets: HashMap::new(), + }, + ); Ok(output_offset) } @@ -751,7 +774,8 @@ impl<'a> BytecodeCodegen<'a> { }); let bytes = encode_json_blob(&literal.to_string()); let offset = self.layout.alloc(bytes.len(), 8); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); return Ok(offset); } @@ -759,22 +783,28 @@ impl<'a> BytecodeCodegen<'a> { Some(DataSource::Constant(value)) => { let bytes = encode_json_blob(&value.to_string()); let offset = self.layout.alloc(bytes.len(), 8); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); Ok(offset) } - Some(DataSource::Connection { source_node_id, source_pin }) => { + Some(DataSource::Connection { + source_node_id, + source_pin, + }) => { self.ensure_connected_output(source_node_id)?; - self.output_offset_for(source_node_id, source_pin).ok_or_else(|| { - GraphyError::Custom(format!( - "No arena offset for output '{}.{}'", - source_node_id, source_pin - )) - }) + self.output_offset_for(source_node_id, source_pin) + .ok_or_else(|| { + GraphyError::Custom(format!( + "No arena offset for output '{}.{}'", + source_node_id, source_pin + )) + }) } Some(DataSource::Default) => { let bytes = encode_json_blob("\"\""); let offset = self.layout.alloc(bytes.len(), 8); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); Ok(offset) } None => Err(GraphyError::Custom(format!( @@ -803,10 +833,14 @@ impl<'a> BytecodeCodegen<'a> { Some(DataSource::Constant(value)) => { let bytes = encode_json_blob(&value.to_string()); let offset = self.layout.alloc(bytes.len(), 8); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); Ok(offset) } - Some(DataSource::Connection { source_node_id, source_pin }) => { + Some(DataSource::Connection { + source_node_id, + source_pin, + }) => { let accepts = self .graph .nodes @@ -822,17 +856,19 @@ impl<'a> BytecodeCodegen<'a> { ))); } self.ensure_connected_output(source_node_id)?; - self.output_offset_for(source_node_id, source_pin).ok_or_else(|| { - GraphyError::Custom(format!( - "No arena offset for output '{}.{}'", - source_node_id, source_pin - )) - }) + self.output_offset_for(source_node_id, source_pin) + .ok_or_else(|| { + GraphyError::Custom(format!( + "No arena offset for output '{}.{}'", + source_node_id, source_pin + )) + }) } Some(DataSource::Default) => { let bytes = encode_json_blob("null"); let offset = self.layout.alloc(bytes.len(), 8); - self.instructions.push(Instruction::InitBytes { offset, bytes }); + self.instructions + .push(Instruction::InitBytes { offset, bytes }); Ok(offset) } None => Err(GraphyError::Custom(format!( @@ -919,10 +955,13 @@ impl<'a> BytecodeCodegen<'a> { output_offset, size: var_size, }); - self.output_offsets.insert(node.id.clone(), OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }); + self.output_offsets.insert( + node.id.clone(), + OutputSlot { + base_offset: output_offset, + field_offsets: HashMap::new(), + }, + ); Ok(output_offset) } @@ -1429,12 +1468,13 @@ impl<'a> BytecodeCodegen<'a> { source_pin, }) => { self.ensure_connected_output(source_node_id)?; - self.output_offset_for(source_node_id, source_pin).ok_or_else(|| { - GraphyError::Custom(format!( - "No arena offset for output '{}.{}'", - source_node_id, source_pin - )) - }) + self.output_offset_for(source_node_id, source_pin) + .ok_or_else(|| { + GraphyError::Custom(format!( + "No arena offset for output '{}.{}'", + source_node_id, source_pin + )) + }) } Some(DataSource::Constant(val)) => { @@ -1552,11 +1592,15 @@ impl<'a> BytecodeCodegen<'a> { fn is_identity_producer(kind: CompOpKind) -> bool { matches!( kind, - CompOpKind::GetRef | CompOpKind::FindByStableId | CompOpKind::FindByName | CompOpKind::ObjectLiteral + CompOpKind::GetRef + | CompOpKind::FindByStableId + | CompOpKind::FindByName + | CompOpKind::ObjectLiteral ) } -fn variable_layout_for_type(type_str: &str) -> Option<(usize, usize)> { match type_str.trim() { +fn variable_layout_for_type(type_str: &str) -> Option<(usize, usize)> { + match type_str.trim() { "bool" | "i8" | "u8" => Some((1, 1)), "i16" | "u16" => Some((2, 2)), "i32" | "u32" | "f32" | "char" => Some((4, 4)), diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index ce1d9f6..79cde1a 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -2,10 +2,10 @@ //! //! Rust code generation and bytecode generation for Blueprint graphs. -mod rust_codegen; pub mod bytecode_codegen; #[allow(dead_code)] mod node_handlers; +mod rust_codegen; -pub use rust_codegen::*; pub use bytecode_codegen::BytecodeCodegen; +pub use rust_codegen::*; diff --git a/src/codegen/rust_codegen.rs b/src/codegen/rust_codegen.rs index 0046887..8d6a0f9 100644 --- a/src/codegen/rust_codegen.rs +++ b/src/codegen/rust_codegen.rs @@ -3,11 +3,10 @@ //! Generates Rust source code from Blueprint graphs. use crate::metadata::BlueprintMetadataProvider; +use graphy::core::NodeMetadataProvider; use graphy::{ - GraphDescription, GraphyError, NodeTypes, NodeInstance, - DataResolver, ExecutionRouting, + DataResolver, ExecutionRouting, GraphDescription, GraphyError, NodeInstance, NodeTypes, }; -use graphy::core::NodeMetadataProvider; use std::collections::{HashMap, HashSet}; /// Blueprint-specific Rust code generator @@ -70,14 +69,16 @@ impl<'a> BlueprintCodeGenerator<'a> { } // Find event nodes - let event_nodes: Vec<_> = self.graph + let event_nodes: Vec<_> = self + .graph .nodes .values() .filter(|node| { // Custom event On nodes (node_type starts with "on_") are synthetic — // no static metadata entry, but still treated as event entry points. node.node_type.starts_with("on_") - || self.metadata_provider + || self + .metadata_provider .get_node_metadata(&node.node_type) .map(|meta| meta.node_type == NodeTypes::event) .unwrap_or(false) @@ -93,7 +94,8 @@ impl<'a> BlueprintCodeGenerator<'a> { // Generate `#[pulsar_event]` structs for custom events for event_node in &event_nodes { if event_node.node_type.starts_with("on_") { - let event_name = pascal_case(&event_node.node_type.strip_prefix("on_").unwrap_or("custom")); + let event_name = + pascal_case(&event_node.node_type.strip_prefix("on_").unwrap_or("custom")); code.push_str(&format!("#[pulsar_event]\npub struct {} {{\n", event_name)); // Emit fields from output data pins (skip execution pins) for pin in &event_node.outputs { @@ -110,7 +112,8 @@ impl<'a> BlueprintCodeGenerator<'a> { } // Also collect custom event node types referenced by emit_custom_event nodes - let custom_event_structs: std::collections::HashSet = self.graph + let custom_event_structs: std::collections::HashSet = self + .graph .nodes .values() .filter(|n| n.node_type == "emit_custom_event") @@ -179,7 +182,9 @@ impl<'a> BlueprintCodeGenerator<'a> { code.push_str(" slot.set(Some(value));\n"); code.push_str("}\n\n"); code.push_str("#[inline]\n"); - code.push_str("fn __pbgc_get_copy(slot: &Cell>, var_name: &str) -> T {\n"); + code.push_str( + "fn __pbgc_get_copy(slot: &Cell>, var_name: &str) -> T {\n", + ); code.push_str(" slot.get().unwrap_or_else(|| panic!(\"PBGC variable '{}' read before assignment\", var_name))\n"); code.push_str("}\n\n"); code.push_str("#[inline]\n"); @@ -187,7 +192,9 @@ impl<'a> BlueprintCodeGenerator<'a> { code.push_str(" *slot.borrow_mut() = Some(value);\n"); code.push_str("}\n\n"); code.push_str("#[inline]\n"); - code.push_str("fn __pbgc_get_clone(slot: &RefCell>, var_name: &str) -> T {\n"); + code.push_str( + "fn __pbgc_get_clone(slot: &RefCell>, var_name: &str) -> T {\n", + ); code.push_str(" slot.borrow()\n"); code.push_str(" .as_ref()\n"); code.push_str(" .cloned()\n"); @@ -213,7 +220,9 @@ impl<'a> BlueprintCodeGenerator<'a> { // Custom event nodes don't have static metadata — infer from the node itself. if event_node.node_type.starts_with("on_") { let func_name = event_node.node_type.clone(); - let mut params: Vec = event_node.outputs.iter() + let mut params: Vec = event_node + .outputs + .iter() .filter(|p| !matches!(p.pin.data_type, graphy::DataType::Exec)) .map(|p| { let type_str = match &p.pin.data_type { @@ -234,7 +243,9 @@ impl<'a> BlueprintCodeGenerator<'a> { // Follow execution chain for output_pin in &event_node.outputs { if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self.exec_routing.get_connected_nodes(&event_node.id, &output_pin.id); + let connected = self + .exec_routing + .get_connected_nodes(&event_node.id, &output_pin.id); for next_node_id in connected { if let Some(next_node) = self.graph.nodes.get(next_node_id) { let mut generator = self.clone_with_new_visited(); @@ -249,17 +260,24 @@ impl<'a> BlueprintCodeGenerator<'a> { } // Get event metadata (for standard event nodes) - let metadata = self.metadata_provider + let metadata = self + .metadata_provider .get_node_metadata(&event_node.node_type) .ok_or_else(|| GraphyError::NodeNotFound(event_node.node_type.clone()))?; // Generate function signature — include any event parameters (e.g. delta_time for on_tick), // then the live-world slice every generated function receives (#651). - let mut params: Vec = metadata.params.iter() + let mut params: Vec = metadata + .params + .iter() .map(|p| format!("{}: {}", p.name, p.param_type)) .collect(); params.push(LIVE_WORLD_PARAMS.to_string()); - code.push_str(&format!("pub fn {}({}) {{\n", metadata.name, params.join(", "))); + code.push_str(&format!( + "pub fn {}({}) {{\n", + metadata.name, + params.join(", ") + )); let pure_preamble = self.generate_pure_node_preamble(1)?; if !pure_preamble.is_empty() { @@ -270,13 +288,18 @@ impl<'a> BlueprintCodeGenerator<'a> { // We need to look up by pin ID (from the node instance), not pin name (from metadata) for output_pin in &event_node.outputs { if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - tracing::debug!("[CODEGEN] Looking up exec connections for node {} pin ID: {}", - event_node.id, output_pin.id); - - let connected = self.exec_routing.get_connected_nodes(&event_node.id, &output_pin.id); - + tracing::debug!( + "[CODEGEN] Looking up exec connections for node {} pin ID: {}", + event_node.id, + output_pin.id + ); + + let connected = self + .exec_routing + .get_connected_nodes(&event_node.id, &output_pin.id); + tracing::debug!("[CODEGEN] Found {} connected nodes", connected.len()); - + for next_node_id in connected { if let Some(next_node) = self.graph.nodes.get(next_node_id) { let mut generator = self.clone_with_new_visited(); @@ -316,16 +339,21 @@ impl<'a> BlueprintCodeGenerator<'a> { .strip_prefix("get_") .filter(|name| self.variables.contains_key(*name)) { - let var_type = self - .variables - .get(var_name) - .ok_or_else(|| GraphyError::Custom(format!("Variable '{}' not found", var_name)))?; + let var_type = self.variables.get(var_name).ok_or_else(|| { + GraphyError::Custom(format!("Variable '{}' not found", var_name)) + })?; let static_name = to_static_var_name(var_name); if is_copy_type(var_type) { - format!("{}.with(|v| __pbgc_get_copy(v, \"{}\"))", static_name, var_name) + format!( + "{}.with(|v| __pbgc_get_copy(v, \"{}\"))", + static_name, var_name + ) } else { - format!("{}.with(|v| __pbgc_get_clone(v, \"{}\"))", static_name, var_name) + format!( + "{}.with(|v| __pbgc_get_clone(v, \"{}\"))", + static_name, var_name + ) } } else { self.generate_pure_node_expression(node)? @@ -347,8 +375,10 @@ impl<'a> BlueprintCodeGenerator<'a> { let mut count = 0usize; for (consumer_id, node) in &self.graph.nodes { for input in &node.inputs { - if let Some(DataSource::Connection { source_node_id: sid, .. }) = - self.data_resolver.get_input_source(consumer_id, &input.id) + if let Some(DataSource::Connection { + source_node_id: sid, + .. + }) = self.data_resolver.get_input_source(consumer_id, &input.id) { if sid == source_node_id { count += 1; @@ -361,7 +391,11 @@ impl<'a> BlueprintCodeGenerator<'a> { } /// Generate execution chain starting from a node - fn generate_exec_chain(&mut self, node: &NodeInstance, indent_level: usize) -> Result { + fn generate_exec_chain( + &mut self, + node: &NodeInstance, + indent_level: usize, + ) -> Result { let code = String::new(); // Prevent infinite loops @@ -404,7 +438,8 @@ impl<'a> BlueprintCodeGenerator<'a> { return self.generate_setter_node(node, indent_level); } - let node_meta = self.metadata_provider + let node_meta = self + .metadata_provider .get_node_metadata(&node.node_type) .ok_or_else(|| GraphyError::NodeNotFound(node.node_type.clone()))?; @@ -413,9 +448,7 @@ impl<'a> BlueprintCodeGenerator<'a> { // Pure nodes are pre-evaluated, skip in exec chain Ok(code) } - NodeTypes::fn_ => { - self.generate_function_node(node, node_meta, indent_level) - } + NodeTypes::fn_ => self.generate_function_node(node, node_meta, indent_level), NodeTypes::control_flow => { self.generate_control_flow_node(node, node_meta, indent_level) } @@ -449,7 +482,9 @@ impl<'a> BlueprintCodeGenerator<'a> { let rest = node .node_type .strip_prefix("comp_get_prop::") - .ok_or_else(|| GraphyError::Custom(format!("Bad comp_get_prop node: {}", node.node_type)))?; + .ok_or_else(|| { + GraphyError::Custom(format!("Bad comp_get_prop node: {}", node.node_type)) + })?; let mut parts = rest.splitn(2, "::"); let class_name = parts .next() @@ -489,10 +524,12 @@ impl<'a> BlueprintCodeGenerator<'a> { ) -> Result { use graphy::analysis::DataSource; - match self.data_resolver.get_input_source(&node.id, "component_ref") { + match self + .data_resolver + .get_input_source(&node.id, "component_ref") + { Some(DataSource::Connection { source_node_id, .. }) => { - let ref_expr = - self.identity_producer_expr(&source_node_id)?; + let ref_expr = self.identity_producer_expr(&source_node_id)?; Ok(format!( "pulsar_game::script_refs::resolve_pin_target(\n\ \x20 _world,\n\ @@ -511,9 +548,11 @@ impl<'a> BlueprintCodeGenerator<'a> { /// (`get_component_ref`, `find_object_by_*`, `object_ref_literal`), /// shared by pure-input inlining and component_ref pin resolution (#654). fn identity_producer_expr(&self, source_node_id: &str) -> Result { - let node = self.graph.nodes.get(source_node_id).ok_or_else(|| { - GraphyError::NodeNotFound(source_node_id.to_string()) - })?; + let node = self + .graph + .nodes + .get(source_node_id) + .ok_or_else(|| GraphyError::NodeNotFound(source_node_id.to_string()))?; if let Some(rest) = node.node_type.strip_prefix("get_component_ref::") { let mut parts = rest.splitn(2, "::"); @@ -610,7 +649,9 @@ impl<'a> BlueprintCodeGenerator<'a> { let rest = node .node_type .strip_prefix("comp_set_prop::") - .ok_or_else(|| GraphyError::Custom(format!("Bad comp_set_prop node: {}", node.node_type)))?; + .ok_or_else(|| { + GraphyError::Custom(format!("Bad comp_set_prop node: {}", node.node_type)) + })?; let mut parts = rest.splitn(2, "::"); let class_name = parts .next() @@ -628,7 +669,9 @@ impl<'a> BlueprintCodeGenerator<'a> { .filter(|p| p.id != "component_ref" && p.pin.name != "component") .find(|p| p.pin.name == "value" || !matches!(p.pin.data_type, graphy::DataType::Exec)) .map(|p| p.id.clone()) - .ok_or_else(|| GraphyError::Custom(format!("No value pin on comp_set_prop node: {}", node.id)))?; + .ok_or_else(|| { + GraphyError::Custom(format!("No value pin on comp_set_prop node: {}", node.id)) + })?; let value_expr = self.generate_input_expression(&node.id, &value_pin_id)?; let target_expr = self.generate_pin_target_expr(node, class_name)?; @@ -653,7 +696,8 @@ impl<'a> BlueprintCodeGenerator<'a> { ); // Follow exec chain. - self.follow_exec_outputs(node, indent_level).map(|chain| format!("{code}{chain}")) + self.follow_exec_outputs(node, indent_level) + .map(|chain| format!("{code}{chain}")) } /// Generate code for `comp_call::{ClassName}::{MethodName}` (exec node). @@ -665,10 +709,9 @@ impl<'a> BlueprintCodeGenerator<'a> { node: &NodeInstance, indent_level: usize, ) -> Result { - let rest = node - .node_type - .strip_prefix("comp_call::") - .ok_or_else(|| GraphyError::Custom(format!("Bad comp_call node: {}", node.node_type)))?; + let rest = node.node_type.strip_prefix("comp_call::").ok_or_else(|| { + GraphyError::Custom(format!("Bad comp_call node: {}", node.node_type)) + })?; let mut parts = rest.splitn(2, "::"); let class_name = parts .next() @@ -758,7 +801,8 @@ r#"{indent}match {target_expr} {{ } // Follow exec chain. - self.follow_exec_outputs(node, indent_level).map(|chain| format!("{code}{chain}")) + self.follow_exec_outputs(node, indent_level) + .map(|chain| format!("{code}{chain}")) } /// Emit every exec-chain successor of `node` at the same indent level. @@ -773,7 +817,9 @@ r#"{indent}match {target_expr} {{ let mut code = String::new(); for output_pin in &node.outputs { if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self.exec_routing.get_connected_nodes(&node.id, &output_pin.id); + let connected = self + .exec_routing + .get_connected_nodes(&node.id, &output_pin.id); for next_node_id in connected { if let Some(next_node) = self.graph.nodes.get(next_node_id) { let next_code = self.generate_exec_chain(next_node, indent_level)?; @@ -816,9 +862,12 @@ r#"{indent}match {target_expr} {{ )); } else if !outputs.is_empty() { // Single output (the default "result" pin) - let result_var = self.data_resolver + let result_var = self + .data_resolver .get_result_variable(&node.id) - .ok_or_else(|| GraphyError::Custom(format!("No result variable for node: {}", node.id)))?; + .ok_or_else(|| { + GraphyError::Custom(format!("No result variable for node: {}", node.id)) + })?; code.push_str(&format!( "{}let {} = {}({});\n", @@ -840,7 +889,9 @@ r#"{indent}match {target_expr} {{ // Follow execution chain - look up by actual pin IDs from node instance for output_pin in &node.outputs { if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self.exec_routing.get_connected_nodes(&node.id, &output_pin.id); + let connected = self + .exec_routing + .get_connected_nodes(&node.id, &output_pin.id); for next_node_id in connected { if let Some(next_node) = self.graph.nodes.get(next_node_id) { let next_code = self.generate_exec_chain(next_node, indent_level)?; @@ -868,7 +919,9 @@ r#"{indent}match {target_expr} {{ for output_pin in &node.outputs { if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self.exec_routing.get_connected_nodes(&node.id, &output_pin.id); + let connected = self + .exec_routing + .get_connected_nodes(&node.id, &output_pin.id); let mut exec_code = String::new(); let local_visited = self.visited.clone(); @@ -898,12 +951,17 @@ r#"{indent}match {target_expr} {{ let mut param_substitutions = HashMap::new(); for param in &node_meta.params { // Find the actual pin ID from the node instance - let pin_id = node.inputs.iter() + let pin_id = node + .inputs + .iter() .find(|input| input.pin.name == param.name) .map(|input| input.id.clone()) - .ok_or_else(|| GraphyError::Custom( - format!("Input pin not found for parameter '{}' on node '{}'", param.name, node.id) - ))?; + .ok_or_else(|| { + GraphyError::Custom(format!( + "Input pin not found for parameter '{}' on node '{}'", + param.name, node.id + )) + })?; let value = self.generate_input_expression(&node.id, &pin_id)?; param_substitutions.insert(param.name.clone(), value); @@ -927,26 +985,35 @@ r#"{indent}match {target_expr} {{ } /// Generate code for a setter node - fn generate_setter_node(&mut self, node: &NodeInstance, indent_level: usize) -> Result { + fn generate_setter_node( + &mut self, + node: &NodeInstance, + indent_level: usize, + ) -> Result { let mut code = String::new(); let indent = " ".repeat(indent_level); // Extract variable name from node type (remove "set_" prefix) - let var_name = node.node_type - .strip_prefix("set_") - .ok_or_else(|| GraphyError::Custom(format!("Invalid setter node type: {}", node.node_type)))?; + let var_name = node.node_type.strip_prefix("set_").ok_or_else(|| { + GraphyError::Custom(format!("Invalid setter node type: {}", node.node_type)) + })?; // Find the "value" input pin ID - let value_pin_id = node.inputs.iter() + let value_pin_id = node + .inputs + .iter() .find(|input| input.pin.name == "value") .map(|input| input.id.clone()) - .ok_or_else(|| GraphyError::Custom(format!("Value input not found on setter node: {}", node.id)))?; + .ok_or_else(|| { + GraphyError::Custom(format!("Value input not found on setter node: {}", node.id)) + })?; // Get the value to set let value_expr = self.generate_input_expression(&node.id, &value_pin_id)?; // Get variable type to determine Cell vs RefCell - let var_type = self.variables + let var_type = self + .variables .get(var_name) .ok_or_else(|| GraphyError::Custom(format!("Variable '{}' not found", var_name)))?; @@ -957,23 +1024,21 @@ r#"{indent}match {target_expr} {{ if is_copy_type { code.push_str(&format!( "{}{}.with(|v| __pbgc_set_copy(v, {}));\n", - indent, - static_name, - value_expr + indent, static_name, value_expr )); } else { code.push_str(&format!( "{}{}.with(|v| __pbgc_set_clone(v, {}));\n", - indent, - static_name, - value_expr + indent, static_name, value_expr )); } // Follow execution chain - use actual pin IDs from node instance for output_pin in &node.outputs { if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self.exec_routing.get_connected_nodes(&node.id, &output_pin.id); + let connected = self + .exec_routing + .get_connected_nodes(&node.id, &output_pin.id); for next_node_id in connected { if let Some(next_node) = self.graph.nodes.get(next_node_id) { let next_code = self.generate_exec_chain(next_node, indent_level)?; @@ -987,21 +1052,30 @@ r#"{indent}match {target_expr} {{ } /// Collect arguments for a function call - fn collect_arguments(&self, node: &NodeInstance, node_meta: &graphy::core::NodeMetadata) -> Result, GraphyError> { + fn collect_arguments( + &self, + node: &NodeInstance, + node_meta: &graphy::core::NodeMetadata, + ) -> Result, GraphyError> { let mut args = Vec::new(); for param in &node_meta.params { // Find the actual pin ID from the node instance // Pin IDs are typically "{node_id}_{param_name}" - let pin_id = node.inputs.iter() + let pin_id = node + .inputs + .iter() .find(|input| { // Match by name - the pin's name should match the param name input.pin.name == param.name }) .map(|input| input.id.clone()) - .ok_or_else(|| GraphyError::Custom( - format!("Input pin not found for parameter '{}' on node '{}'", param.name, node.id) - ))?; + .ok_or_else(|| { + GraphyError::Custom(format!( + "Input pin not found for parameter '{}' on node '{}'", + param.name, node.id + )) + })?; let value = self.generate_input_expression(&node.id, &pin_id)?; args.push(value); @@ -1012,7 +1086,11 @@ r#"{indent}match {target_expr} {{ /// Generate expression for an input value /// pin_id should be the actual pin ID from the node instance (e.g., "print_1_value") - fn generate_input_expression(&self, node_id: &str, pin_id: &str) -> Result { + fn generate_input_expression( + &self, + node_id: &str, + pin_id: &str, + ) -> Result { use graphy::analysis::DataSource; /// Resolve the output pin name on the source node for a multi-output accessor. @@ -1025,23 +1103,33 @@ r#"{indent}match {target_expr} {{ let src_node = graph.nodes.get(source_node_id)?; let src_meta = metadata_provider.get_node_metadata(&src_node.node_type)?; // Find the pin name by matching pin instance ID - let pin_name = src_node.outputs.iter() + let pin_name = src_node + .outputs + .iter() .find(|p| p.id == source_pin_id) .map(|p| p.pin.name.as_str())?; let outputs = src_meta.effective_outputs(); if outputs.len() <= 1 { return None; } - outputs.iter() - .find(|o| o.name == pin_name) - .and_then(|o| { - if o.accessor.is_empty() { None } else { Some(o.accessor.clone()) } - }) + outputs.iter().find(|o| o.name == pin_name).and_then(|o| { + if o.accessor.is_empty() { + None + } else { + Some(o.accessor.clone()) + } + }) } match self.data_resolver.get_input_source(node_id, pin_id) { - Some(DataSource::Connection { source_node_id, source_pin }) => { - let source_node = self.graph.nodes.get(source_node_id) + Some(DataSource::Connection { + source_node_id, + source_pin, + }) => { + let source_node = self + .graph + .nodes + .get(source_node_id) .ok_or_else(|| GraphyError::NodeNotFound(source_node_id.clone()))?; // Check if source is a component property getter (pure) @@ -1068,28 +1156,43 @@ r#"{indent}match {target_expr} {{ .strip_prefix("get_") .filter(|name| self.variables.contains_key(*name)) { - let var_type = self.variables.get(var_name) - .ok_or_else(|| GraphyError::Custom(format!("Variable '{}' not found", var_name)))?; + let var_type = self.variables.get(var_name).ok_or_else(|| { + GraphyError::Custom(format!("Variable '{}' not found", var_name)) + })?; let static_name = to_static_var_name(var_name); let is_copy = is_copy_type(var_type); return if is_copy { - Ok(format!("{}.with(|v| __pbgc_get_copy(v, \"{}\"))", static_name, var_name)) + Ok(format!( + "{}.with(|v| __pbgc_get_copy(v, \"{}\"))", + static_name, var_name + )) } else { - Ok(format!("{}.with(|v| __pbgc_get_clone(v, \"{}\"))", static_name, var_name)) + Ok(format!( + "{}.with(|v| __pbgc_get_clone(v, \"{}\"))", + static_name, var_name + )) }; } // Check if source is pure. Prefer precomputed temporary results // to avoid duplicating pure function calls across downstream uses. - if let Some(node_meta) = self.metadata_provider.get_node_metadata(&source_node.node_type) { + if let Some(node_meta) = self + .metadata_provider + .get_node_metadata(&source_node.node_type) + { if node_meta.node_type == NodeTypes::pure { if self.should_materialize_pure_result(source_node_id) { - if let Some(var_name) = self.data_resolver.get_result_variable(source_node_id) { + if let Some(var_name) = + self.data_resolver.get_result_variable(source_node_id) + { // Check for multi-output accessor if let Some(acc) = get_multi_output_accessor( - self.metadata_provider, self.graph, source_node_id, &source_pin + self.metadata_provider, + self.graph, + source_node_id, + &source_pin, ) { return Ok(format!("{}{}", var_name, acc)); } @@ -1099,7 +1202,10 @@ r#"{indent}match {target_expr} {{ let expr = self.generate_pure_node_expression(source_node)?; // Check for multi-output accessor on the inline expression if let Some(acc) = get_multi_output_accessor( - self.metadata_provider, self.graph, source_node_id, &source_pin + self.metadata_provider, + self.graph, + source_node_id, + &source_pin, ) { return Ok(format!("({}){}", expr, acc)); } @@ -1111,14 +1217,20 @@ r#"{indent}match {target_expr} {{ if let Some(var_name) = self.data_resolver.get_result_variable(source_node_id) { // Check for multi-output accessor if let Some(acc) = get_multi_output_accessor( - self.metadata_provider, self.graph, source_node_id, &source_pin + self.metadata_provider, + self.graph, + source_node_id, + &source_pin, ) { Ok(format!("{}{}", var_name, acc)) } else { Ok(var_name.clone()) } } else { - Err(GraphyError::Custom(format!("No variable for source node: {}", source_node_id))) + Err(GraphyError::Custom(format!( + "No variable for source node: {}", + source_node_id + ))) } } Some(DataSource::Constant(value)) => Ok(value.clone()), @@ -1137,13 +1249,17 @@ r#"{indent}match {target_expr} {{ Err(GraphyError::NodeNotFound(node_id.to_string())) } } - None => Err(GraphyError::Custom(format!("No data source for input: {}.{}", node_id, pin_id))), + None => Err(GraphyError::Custom(format!( + "No data source for input: {}.{}", + node_id, pin_id + ))), } } /// Generate inlined expression for a pure node fn generate_pure_node_expression(&self, node: &NodeInstance) -> Result { - let node_meta = self.metadata_provider + let node_meta = self + .metadata_provider .get_node_metadata(&node.node_type) .ok_or_else(|| GraphyError::NodeNotFound(node.node_type.clone()))?; @@ -1151,12 +1267,17 @@ r#"{indent}match {target_expr} {{ let mut args = Vec::new(); for param in &node_meta.params { // Find the actual pin ID from the node instance - let pin_id = node.inputs.iter() + let pin_id = node + .inputs + .iter() .find(|input| input.pin.name == param.name) .map(|input| input.id.clone()) - .ok_or_else(|| GraphyError::Custom( - format!("Input pin not found for parameter '{}' on node '{}'", param.name, node.id) - ))?; + .ok_or_else(|| { + GraphyError::Custom(format!( + "Input pin not found for parameter '{}' on node '{}'", + param.name, node.id + )) + })?; let arg_expr = self.generate_input_expression(&node.id, &pin_id)?; args.push(arg_expr); @@ -1182,8 +1303,20 @@ r#"{indent}match {target_expr} {{ fn is_copy_type(type_str: &str) -> bool { matches!( type_str, - "i32" | "i64" | "u32" | "u64" | "f32" | "f64" | "bool" | "char" | - "usize" | "isize" | "i8" | "i16" | "u8" | "u16" + "i32" + | "i64" + | "u32" + | "u64" + | "f32" + | "f64" + | "bool" + | "char" + | "usize" + | "isize" + | "i8" + | "i16" + | "u8" + | "u16" ) } diff --git a/src/compiler.rs b/src/compiler.rs index 23be041..20f8065 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -2,14 +2,13 @@ //! //! Main entry points for compiling Blueprint graphs to Rust code. -use crate::metadata::{BlueprintMetadataProvider, get_node_metadata}; -use crate::codegen::{BlueprintCodeGenerator, BytecodeCodegen}; use crate::bytecode::comp_ops::ComponentOpRef; use crate::bytecode::BpProgram; -use graphy::{GraphDescription, GraphyError, DataResolver, ExecutionRouting}; +use crate::codegen::{BlueprintCodeGenerator, BytecodeCodegen}; +use crate::metadata::{get_node_metadata, BlueprintMetadataProvider}; +use graphy::{DataResolver, ExecutionRouting, GraphDescription, GraphyError}; use std::collections::HashMap; - /// Compile a Blueprint graph to Rust source code /// /// This is the main entry point for the Blueprint compiler. It takes a visual node graph @@ -60,10 +59,12 @@ pub fn compile_graph_with_library_manager( _library_manager: Option<()>, // TODO: Define LibraryManager type ) -> Result { tracing::info!("[PBGC] Starting Blueprint compilation"); - tracing::info!("[PBGC] Graph: {} ({} nodes, {} connections)", + tracing::info!( + "[PBGC] Graph: {} ({} nodes, {} connections)", graph.metadata.name, graph.nodes.len(), - graph.connections.len()); + graph.connections.len() + ); // Create a mutable copy for expansion let expanded_graph = graph.clone(); @@ -84,8 +85,10 @@ pub fn compile_graph_with_library_manager( tracing::info!("[PBGC] Phase 2: Analyzing data flow..."); let data_resolver = DataResolver::build(&expanded_graph, &metadata_provider)?; tracing::info!("[PBGC] Data flow analysis complete"); - tracing::info!("[PBGC] - {} pure nodes in evaluation order", - data_resolver.get_pure_evaluation_order().len()); + tracing::info!( + "[PBGC] - {} pure nodes in evaluation order", + data_resolver.get_pure_evaluation_order().len() + ); // Phase 3: Build execution routing tracing::info!("[PBGC] Phase 3: Analyzing execution flow..."); @@ -192,8 +195,12 @@ pub fn compile_graph_to_bytecode_full( variables: HashMap, ) -> Result { tracing::info!("[PBGC] Starting bytecode compilation"); - tracing::info!("[PBGC] Graph: {} ({} nodes, {} connections)", - graph.metadata.name, graph.nodes.len(), graph.connections.len()); + tracing::info!( + "[PBGC] Graph: {} ({} nodes, {} connections)", + graph.metadata.name, + graph.nodes.len(), + graph.connections.len() + ); let metadata_provider = BlueprintMetadataProvider::new(); let data_resolver = DataResolver::build(graph, &metadata_provider)?; @@ -214,7 +221,10 @@ pub fn compile_graph_to_bytecode_full( programs.len(), components.len() ); - Ok(BytecodeCompilation { programs, components }) + Ok(BytecodeCompilation { + programs, + components, + }) } #[cfg(test)] @@ -227,7 +237,8 @@ mod tests { fn branch_condition_from_pure_chain_inlines_single_use_pure_nodes() { let mut graph = GraphDescription::new("branch_pure_chain"); - let mut begin = graphy::NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); + let mut begin = + graphy::NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); begin.outputs.push(PinInstance::new( "begin_exec", Pin::new("begin_exec", "Body", DataType::Exec, PinType::Output), @@ -244,12 +255,20 @@ mod tests { )); add.outputs.push(PinInstance::new( "add_result", - Pin::new("add_result", "result", DataType::typed("f64"), PinType::Output), + Pin::new( + "add_result", + "result", + DataType::typed("f64"), + PinType::Output, + ), )); - add.properties.insert("add_a".to_string(), serde_json::json!(1.0)); - add.properties.insert("add_b".to_string(), serde_json::json!(3.0)); + add.properties + .insert("add_a".to_string(), serde_json::json!(1.0)); + add.properties + .insert("add_b".to_string(), serde_json::json!(3.0)); - let mut gt = graphy::NodeInstance::new("gt_node", "greater_than", Position { x: 200.0, y: 0.0 }); + let mut gt = + graphy::NodeInstance::new("gt_node", "greater_than", Position { x: 200.0, y: 0.0 }); gt.inputs.push(PinInstance::new( "gt_a", Pin::new("gt_a", "a", DataType::typed("f64"), PinType::Input), @@ -260,18 +279,30 @@ mod tests { )); gt.outputs.push(PinInstance::new( "gt_result", - Pin::new("gt_result", "result", DataType::typed("bool"), PinType::Output), + Pin::new( + "gt_result", + "result", + DataType::typed("bool"), + PinType::Output, + ), )); - gt.properties.insert("gt_b".to_string(), serde_json::json!(3.0)); + gt.properties + .insert("gt_b".to_string(), serde_json::json!(3.0)); - let mut branch = graphy::NodeInstance::new("branch_node", "branch", Position { x: 300.0, y: 0.0 }); + let mut branch = + graphy::NodeInstance::new("branch_node", "branch", Position { x: 300.0, y: 0.0 }); branch.inputs.push(PinInstance::new( "branch_exec", Pin::new("branch_exec", "exec", DataType::Exec, PinType::Input), )); branch.inputs.push(PinInstance::new( "branch_condition", - Pin::new("branch_condition", "condition", DataType::typed("bool"), PinType::Input), + Pin::new( + "branch_condition", + "condition", + DataType::typed("bool"), + PinType::Input, + ), )); branch.outputs.push(PinInstance::new( "branch_true", diff --git a/src/disk.rs b/src/disk.rs index 860bf91..e342cdb 100644 --- a/src/disk.rs +++ b/src/disk.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use crate::compiler::{compile_graph, compile_graph_with_variables}; -use crate::project::{CompiledBlueprint, GeneratedProject, ProjectSpec, generate_project}; +use crate::project::{generate_project, CompiledBlueprint, GeneratedProject, ProjectSpec}; use graphy::{DataType, GraphDescription, TypeInfo}; use serde::Deserialize; use std::collections::HashMap; @@ -158,75 +158,77 @@ fn walk_for_blueprints(dir: &Path, out: &mut Vec) { #[cfg(test)] mod tests { - use super::*; - use graphy::{DataType, GraphDescription, NodeInstance, Pin, PinInstance, PinType, Position, TypeInfo}; - - fn unique_temp_dir(prefix: &str) -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("{}_{}", prefix, nanos)) - } + use super::*; + use graphy::{ + DataType, GraphDescription, NodeInstance, Pin, PinInstance, PinType, Position, TypeInfo, + }; - #[test] - fn compile_project_reads_blueprint_asset_variables() { - let root = unique_temp_dir("pbgc_disk_asset_vars"); - let class_dir = root.join("PlayerClass"); - std::fs::create_dir_all(&class_dir).unwrap(); - - let mut graph = GraphDescription::new("player_graph"); - let mut begin = NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); - begin.outputs.push(PinInstance::new( - "begin_exec", - Pin::new("begin_exec", "Body", DataType::Exec, PinType::Output), - )); - graph.add_node(begin); - let graph_value = serde_json::to_value(&graph).unwrap(); - let asset_value = serde_json::json!({ - "format_version": 1, - "mainGraph": graph_value, - "variables": [ - { - "id": "var_1", - "name": "health", - "data_type": graphy::DataType::typed("f64"), - "default_value": "100.0", - "description": "" - } - ] - }); - - std::fs::write( - class_dir.join("graph_save.json"), - serde_json::to_string_pretty(&asset_value).unwrap(), - ) - .unwrap(); - - let compiled_single = compile_blueprint_folder(&class_dir).unwrap(); - assert_eq!(compiled_single.variables.len(), 1); - assert_eq!(compiled_single.variables[0].name, "health"); - - let compiled = compile_project(&root).unwrap(); - assert_eq!(compiled.len(), 1); - assert_eq!(compiled[0].name, "PlayerClass"); - assert_eq!(compiled[0].variables.len(), 1); - assert_eq!(compiled[0].variables[0].name, "health"); - - let generated = compile_project_generated(&root).unwrap(); - assert!(generated - .files - .contains_key("src/classes/player_class/mod.rs")); - assert!(generated - .files - .contains_key("src/classes/player_class/events/mod.rs")); - assert!(generated - .files - .contains_key("src/classes/player_class/events/events.rs")); - assert!(generated - .files - .contains_key("src/classes/player_class/vars/mod.rs")); - - let _ = std::fs::remove_dir_all(&root); - } + fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("{}_{}", prefix, nanos)) + } + + #[test] + fn compile_project_reads_blueprint_asset_variables() { + let root = unique_temp_dir("pbgc_disk_asset_vars"); + let class_dir = root.join("PlayerClass"); + std::fs::create_dir_all(&class_dir).unwrap(); + + let mut graph = GraphDescription::new("player_graph"); + let mut begin = NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); + begin.outputs.push(PinInstance::new( + "begin_exec", + Pin::new("begin_exec", "Body", DataType::Exec, PinType::Output), + )); + graph.add_node(begin); + let graph_value = serde_json::to_value(&graph).unwrap(); + let asset_value = serde_json::json!({ + "format_version": 1, + "mainGraph": graph_value, + "variables": [ + { + "id": "var_1", + "name": "health", + "data_type": graphy::DataType::typed("f64"), + "default_value": "100.0", + "description": "" + } + ] + }); + + std::fs::write( + class_dir.join("graph_save.json"), + serde_json::to_string_pretty(&asset_value).unwrap(), + ) + .unwrap(); + + let compiled_single = compile_blueprint_folder(&class_dir).unwrap(); + assert_eq!(compiled_single.variables.len(), 1); + assert_eq!(compiled_single.variables[0].name, "health"); + + let compiled = compile_project(&root).unwrap(); + assert_eq!(compiled.len(), 1); + assert_eq!(compiled[0].name, "PlayerClass"); + assert_eq!(compiled[0].variables.len(), 1); + assert_eq!(compiled[0].variables[0].name, "health"); + + let generated = compile_project_generated(&root).unwrap(); + assert!(generated + .files + .contains_key("src/classes/player_class/mod.rs")); + assert!(generated + .files + .contains_key("src/classes/player_class/events/mod.rs")); + assert!(generated + .files + .contains_key("src/classes/player_class/events/events.rs")); + assert!(generated + .files + .contains_key("src/classes/player_class/vars/mod.rs")); + + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/src/lib.rs b/src/lib.rs index 45b8fef..16197ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,18 +8,14 @@ pub mod vm; // Rust source codegen pub use compiler::{ - compile_graph, - compile_graph_with_library_manager, - compile_graph_with_variables, - compile_graph_to_bytecode, - compile_graph_to_bytecode_full, - compile_graph_to_bytecode_with_variables, - BytecodeCompilation, + compile_graph, compile_graph_to_bytecode, compile_graph_to_bytecode_full, + compile_graph_to_bytecode_with_variables, compile_graph_with_library_manager, + compile_graph_with_variables, BytecodeCompilation, }; // Bytecode types -pub use bytecode::{BpProgram, Instruction, LabelId}; pub use bytecode::comp_ops::{parse_node_type, CompOpKind, ComponentOpRef}; +pub use bytecode::{BpProgram, Instruction, LabelId}; // VM execution APIs pub use vm::{run, run_with_external_arena, DispatchFn, VmError}; @@ -27,24 +23,17 @@ pub use vm::{run, run_with_external_arena, DispatchFn, VmError}; // Disk / project pub use disk::{compile_project, compile_project_generated}; pub use project::{ - compile_graph_to_actor_source, - CompiledBlueprint, - CompiledComponent, - CompiledVariable, - GeneratedProject, - ProjectSpec, - generate_blueprint_actor_source, - generate_blueprint_actor_source_with_components, - generate_project, + compile_graph_to_actor_source, generate_blueprint_actor_source, + generate_blueprint_actor_source_with_components, generate_project, CompiledBlueprint, + CompiledComponent, CompiledVariable, GeneratedProject, ProjectSpec, }; // Graphy re-exports +pub use graphy::core::TypeInfo; pub use graphy::{ - GraphDescription, NodeInstance, Connection, Pin, PinInstance, - DataType, NodeTypes, Position, ConnectionType, JsonValue, - GraphMetadata, Result, GraphyError, PinType, - NodeMetadata, NodeMetadataProvider, + Connection, ConnectionType, DataType, GraphDescription, GraphMetadata, GraphyError, JsonValue, + NodeInstance, NodeMetadata, NodeMetadataProvider, NodeTypes, Pin, PinInstance, PinType, + Position, Result, }; -pub use graphy::core::TypeInfo; -pub use metadata::{BlueprintMetadataProvider, extract_node_metadata}; +pub use metadata::{extract_node_metadata, BlueprintMetadataProvider}; diff --git a/src/metadata.rs b/src/metadata.rs index e1fca13..42cae96 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -30,16 +30,10 @@ fn convert_node_metadata(ps_node: &pulsar_std::registry::NodeMetadata) -> NodeMe .collect(); // Convert return type - let return_type = ps_node - .return_type - .map(|ty| TypeInfo::new(ty.to_string())); + let return_type = ps_node.return_type.map(|ty| TypeInfo::new(ty.to_string())); // Convert exec outputs - let exec_outputs: Vec = ps_node - .exec_outputs - .iter() - .map(|s| s.to_string()) - .collect(); + let exec_outputs: Vec = ps_node.exec_outputs.iter().map(|s| s.to_string()).collect(); // Convert imports - flatten NodeImport into simple import strings let imports: Vec = ps_node @@ -49,7 +43,11 @@ fn convert_node_metadata(ps_node: &pulsar_std::registry::NodeMetadata) -> NodeMe if import.items.is_empty() { format!("use {};", import.crate_name) } else { - format!("use {}::{{{}}};", import.crate_name, import.items.join(", ")) + format!( + "use {}::{{{}}};", + import.crate_name, + import.items.join(", ") + ) } }) .collect(); @@ -102,7 +100,7 @@ fn convert_node_metadata(ps_node: &pulsar_std::registry::NodeMetadata) -> NodeMe /// in static memory, so multiple threads can access it concurrently without locks. pub fn extract_node_metadata() -> Result, String> { let ps_nodes = pulsar_std::get_all_nodes(); - + let mut metadata = HashMap::new(); for ps_node in ps_nodes { let node = convert_node_metadata(ps_node); @@ -118,16 +116,17 @@ pub fn extract_node_metadata() -> Result, String> /// The metadata is extracted from pulsar_std on first call and cached for /// all subsequent calls. pub fn get_node_metadata() -> &'static HashMap { - NODE_METADATA.get_or_init(|| { - match extract_node_metadata() { - Ok(metadata) => { - tracing::info!("[PBGC] Loaded {} node definitions from pulsar_std", metadata.len()); - metadata - } - Err(e) => { - tracing::error!("[PBGC] Failed to extract node metadata: {}", e); - HashMap::new() - } + NODE_METADATA.get_or_init(|| match extract_node_metadata() { + Ok(metadata) => { + tracing::info!( + "[PBGC] Loaded {} node definitions from pulsar_std", + metadata.len() + ); + metadata + } + Err(e) => { + tracing::error!("[PBGC] Failed to extract node metadata: {}", e); + HashMap::new() } }) } @@ -145,11 +144,10 @@ pub struct BlueprintMetadataProvider { impl BlueprintMetadataProvider { pub fn new() -> Self { - let raw: HashMap = - pulsar_std::get_all_nodes() - .iter() - .map(|m| (m.name.to_string(), m)) - .collect(); + let raw: HashMap = pulsar_std::get_all_nodes() + .iter() + .map(|m| (m.name.to_string(), m)) + .collect(); Self { metadata: get_node_metadata(), raw, diff --git a/src/project.rs b/src/project.rs index 7cbe1de..f98309c 100644 --- a/src/project.rs +++ b/src/project.rs @@ -298,8 +298,7 @@ pub fn generate_blueprint_actor_source_with_components( compiled_source: &str, components: Vec, ) -> String { - let bp = CompiledBlueprint::new(blueprint_name, compiled_source) - .with_components(components); + let bp = CompiledBlueprint::new(blueprint_name, compiled_source).with_components(components); gen_blueprint_actor(&bp) } @@ -364,8 +363,8 @@ fn gen_blueprint_actor(bp: &CompiledBlueprint) -> String { let has_components = !enabled_components.is_empty(); // ── Set up custom events ────────────────────────────────────────────────── - let has_custom_events = bp.source.contains("on_player_died") - || bp.source.contains("emit_event"); + let has_custom_events = + bp.source.contains("on_player_died") || bp.source.contains("emit_event"); let event_helpers = if has_custom_events { format!( r#" @@ -637,8 +636,20 @@ fn default_variable_storage_block(variables: &[CompiledVariable]) -> String { fn is_copy_type(type_str: &str) -> bool { matches!( type_str, - "i32" | "i64" | "u32" | "u64" | "f32" | "f64" | "bool" | "char" | - "usize" | "isize" | "i8" | "i16" | "u8" | "u16" + "i32" + | "i64" + | "u32" + | "u64" + | "f32" + | "f64" + | "bool" + | "char" + | "usize" + | "isize" + | "i8" + | "i16" + | "u8" + | "u16" ) } @@ -710,7 +721,10 @@ mod tests { actor.contains("fn tick(&mut self, _entity: Entity, _world: &mut World)"), "emitted tick signature drifted from the pinned Actor trait" ); - assert!(!actor.contains("GameTime"), "Actor::tick must stay time-free"); + assert!( + !actor.contains("GameTime"), + "Actor::tick must stay time-free" + ); assert!( !actor.contains("gamma_core"), "emitted code may only reference crates present in the pinned graph" @@ -849,9 +863,15 @@ mod tests { project.write_to_dir(&dir).unwrap(); assert!(dir.join("src/classes/mod.rs").exists()); assert!(dir.join("src/classes/player_controller/mod.rs").exists()); - assert!(dir.join("src/classes/player_controller/events/mod.rs").exists()); - assert!(dir.join("src/classes/player_controller/events/events.rs").exists()); - assert!(dir.join("src/classes/player_controller/vars/mod.rs").exists()); + assert!(dir + .join("src/classes/player_controller/events/mod.rs") + .exists()); + assert!(dir + .join("src/classes/player_controller/events/events.rs") + .exists()); + assert!(dir + .join("src/classes/player_controller/vars/mod.rs") + .exists()); assert!(dir.join("src/classes/enemy_ai/mod.rs").exists()); assert!(dir.join("src/classes/enemy_ai/events/mod.rs").exists()); assert!(dir.join("src/classes/enemy_ai/events/events.rs").exists()); diff --git a/src/vm/mod.rs b/src/vm/mod.rs index a454233..c9a6ab4 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -11,10 +11,16 @@ pub enum VmError { impl std::fmt::Display for VmError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - VmError::LabelNotFound(l) => write!(f, "label {} not found", l), - VmError::UnresolvedCall(n) => write!(f, "unresolved call '{}' — forgot BpExecutor::prepare?", n), + VmError::LabelNotFound(l) => write!(f, "label {} not found", l), + VmError::UnresolvedCall(n) => { + write!(f, "unresolved call '{}' — forgot BpExecutor::prepare?", n) + } VmError::InsufficientArena { required, provided } => { - write!(f, "insufficient arena size: required {}, provided {}", required, provided) + write!( + f, + "insufficient arena size: required {}, provided {}", + required, provided + ) } } } @@ -28,11 +34,8 @@ impl std::error::Error for VmError {} /// `type_slots` — array of `TypeSlot` values resolved at graph-compile time, /// one per generic type parameter `T`. Concrete functions /// receive this pointer but must not read it (pass null/empty). -pub type DispatchFn = unsafe extern "C" fn( - args: *const *const u8, - ret: *mut u8, - type_slots: *const TypeSlot, -); +pub type DispatchFn = + unsafe extern "C" fn(args: *const *const u8, ret: *mut u8, type_slots: *const TypeSlot); /// Execute a prepared `BpProgram`. /// @@ -74,10 +77,10 @@ pub unsafe fn run_with_external_arena( let base = arena; // Pre-allocate the argument-pointer scratch buffer once to avoid heap churn. - let mut arg_ptrs: Vec<*const u8> = Vec::with_capacity(program.max_args_count.max(1)); + let mut arg_ptrs: Vec<*const u8> = Vec::with_capacity(program.max_args_count.max(1)); // Scratch buffer: TypeSlot values collected contiguously for each generic Call. // Generic dispatch functions receive `*const TypeSlot` into this buffer. - let mut type_slots_buf: Vec = Vec::new(); + let mut type_slots_buf: Vec = Vec::new(); let labels = build_labels(&program.instructions); let mut pc = 0usize; @@ -95,25 +98,44 @@ pub unsafe fn run_with_external_arena( pc += 1; } - Instruction::InitTypeSlot { offset, size, align } => { + Instruction::InitTypeSlot { + offset, + size, + align, + } => { // Write a TypeSlot { size, align } into the arena at *offset*. // SAFETY: the codegen guarantees offset + sizeof(TypeSlot) <= arena_size // and that the slot is 8-byte aligned. unsafe { let slot_ptr = base.add(*offset) as *mut TypeSlot; - std::ptr::write(slot_ptr, TypeSlot { size: *size, align: *align }); + std::ptr::write( + slot_ptr, + TypeSlot { + size: *size, + align: *align, + }, + ); } pc += 1; } - Instruction::Call { fn_ptr, node_type, input_offsets, output_offset, has_output, type_slot_offsets } => { + Instruction::Call { + fn_ptr, + node_type, + input_offsets, + output_offset, + has_output, + type_slot_offsets, + } => { if *fn_ptr == 0 { return Err(VmError::UnresolvedCall(node_type.clone())); } arg_ptrs.clear(); for &off in input_offsets { // SAFETY: offset is within the arena (guaranteed by codegen). - unsafe { arg_ptrs.push(base.add(off)); } + unsafe { + arg_ptrs.push(base.add(off)); + } } // Collect TypeSlot values into a contiguous scratch buffer so that // generic dispatch functions can index them as type_slots[i]. @@ -124,7 +146,11 @@ pub unsafe fn run_with_external_arena( } } unsafe { - let ret = if *has_output { base.add(*output_offset) } else { std::ptr::null_mut() }; + let ret = if *has_output { + base.add(*output_offset) + } else { + std::ptr::null_mut() + }; // Null when there are no type slots (concrete functions). let ts_ptr = if type_slots_buf.is_empty() { std::ptr::null() @@ -138,29 +164,33 @@ pub unsafe fn run_with_external_arena( pc += 1; } - Instruction::LoadVar { source_offset, output_offset, size } => { + Instruction::LoadVar { + source_offset, + output_offset, + size, + } => { unsafe { - std::ptr::copy( - base.add(*source_offset), - base.add(*output_offset), - *size, - ); + std::ptr::copy(base.add(*source_offset), base.add(*output_offset), *size); } pc += 1; } - Instruction::StoreVar { input_offset, target_offset, size } => { + Instruction::StoreVar { + input_offset, + target_offset, + size, + } => { unsafe { - std::ptr::copy( - base.add(*input_offset), - base.add(*target_offset), - *size, - ); + std::ptr::copy(base.add(*input_offset), base.add(*target_offset), *size); } pc += 1; } - Instruction::JumpIf { condition_offset, true_label, false_label } => { + Instruction::JumpIf { + condition_offset, + true_label, + false_label, + } => { // bool is 1 byte; non-zero == true. let cond = unsafe { *base.add(*condition_offset) != 0 }; let target = if cond { *true_label } else { *false_label }; @@ -171,7 +201,9 @@ pub unsafe fn run_with_external_arena( pc = *labels.get(label).ok_or(VmError::LabelNotFound(*label))? + 1; } - Instruction::Label(_) => { pc += 1; } + Instruction::Label(_) => { + pc += 1; + } Instruction::Return => break, } @@ -180,9 +212,15 @@ pub unsafe fn run_with_external_arena( } fn build_labels(instructions: &[Instruction]) -> HashMap { - instructions.iter().enumerate() + instructions + .iter() + .enumerate() .filter_map(|(i, instr)| { - if let Instruction::Label(id) = instr { Some((*id, i)) } else { None } + if let Instruction::Label(id) = instr { + Some((*id, i)) + } else { + None + } }) .collect() } diff --git a/tests/bytecode_tests.rs b/tests/bytecode_tests.rs index 3d6a6b0..643db01 100644 --- a/tests/bytecode_tests.rs +++ b/tests/bytecode_tests.rs @@ -9,8 +9,11 @@ use graphy::{ Connection, ConnectionType, DataType, GraphDescription, NodeInstance, Pin, PinInstance, PinType, Position, }; +use pbgc::{ + compile_graph, compile_graph_to_bytecode, compile_graph_to_bytecode_with_variables, BpProgram, + Instruction, +}; use std::collections::HashMap; -use pbgc::{compile_graph, compile_graph_to_bytecode, compile_graph_to_bytecode_with_variables, BpProgram, Instruction}; // ── Native dispatch shims (mirrors what pulsar_macros #[blueprint] generates) ─ // @@ -19,58 +22,58 @@ use pbgc::{compile_graph, compile_graph_to_bytecode, compile_graph_to_bytecode_w // ret → pointer to the output region in the arena unsafe extern "C" fn shim_add(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); + let a = std::ptr::read(*args as *const i64); + let b = std::ptr::read(*args.add(1) as *const i64); std::ptr::write(ret as *mut i64, a + b); } unsafe extern "C" fn shim_subtract(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); + let a = std::ptr::read(*args as *const i64); + let b = std::ptr::read(*args.add(1) as *const i64); std::ptr::write(ret as *mut i64, a - b); } unsafe extern "C" fn shim_multiply(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); + let a = std::ptr::read(*args as *const i64); + let b = std::ptr::read(*args.add(1) as *const i64); std::ptr::write(ret as *mut i64, a * b); } unsafe extern "C" fn shim_divide(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); + let a = std::ptr::read(*args as *const i64); + let b = std::ptr::read(*args.add(1) as *const i64); std::ptr::write(ret as *mut i64, if b == 0 { 0 } else { a / b }); } unsafe extern "C" fn shim_modulo(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); + let a = std::ptr::read(*args as *const i64); + let b = std::ptr::read(*args.add(1) as *const i64); std::ptr::write(ret as *mut i64, if b == 0 { 0 } else { a % b }); } unsafe extern "C" fn shim_greater_than(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const f64); - let b = std::ptr::read(*args.add(1) as *const f64); + let a = std::ptr::read(*args as *const f64); + let b = std::ptr::read(*args.add(1) as *const f64); std::ptr::write(ret as *mut bool, a > b); } unsafe extern "C" fn shim_less_than(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const f64); - let b = std::ptr::read(*args.add(1) as *const f64); + let a = std::ptr::read(*args as *const f64); + let b = std::ptr::read(*args.add(1) as *const f64); std::ptr::write(ret as *mut bool, a < b); } unsafe extern "C" fn shim_abs(args: *const *const u8, ret: *mut u8) { - let v = std::ptr::read(*args as *const f64); + let v = std::ptr::read(*args as *const f64); std::ptr::write(ret as *mut f64, v.abs()); } unsafe extern "C" fn shim_sqrt(args: *const *const u8, ret: *mut u8) { - let v = std::ptr::read(*args as *const f64); + let v = std::ptr::read(*args as *const f64); std::ptr::write(ret as *mut f64, v.sqrt()); } unsafe extern "C" fn shim_lerp(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const f64); - let b = std::ptr::read(*args.add(1) as *const f64); - let t = std::ptr::read(*args.add(2) as *const f64); + let a = std::ptr::read(*args as *const f64); + let b = std::ptr::read(*args.add(1) as *const f64); + let t = std::ptr::read(*args.add(2) as *const f64); std::ptr::write(ret as *mut f64, a + (b - a) * t); } unsafe extern "C" fn shim_clamp(args: *const *const u8, ret: *mut u8) { - let v = std::ptr::read(*args as *const f64); - let min = std::ptr::read(*args.add(1) as *const f64); - let max = std::ptr::read(*args.add(2) as *const f64); + let v = std::ptr::read(*args as *const f64); + let min = std::ptr::read(*args.add(1) as *const f64); + let max = std::ptr::read(*args.add(2) as *const f64); std::ptr::write(ret as *mut f64, v.clamp(min, max)); } unsafe extern "C" fn shim_print_string(_args: *const *const u8, _ret: *mut u8) {} @@ -86,18 +89,21 @@ unsafe extern "C" fn shim_assert_false(args: *const *const u8, _ret: *mut u8) { assert!(!cond, "assert_false failed: condition was true"); } unsafe extern "C" fn shim_assert_eq_int(args: *const *const u8, _ret: *mut u8) { - let actual = std::ptr::read(*args as *const i64); + let actual = std::ptr::read(*args as *const i64); let expected = std::ptr::read(*args.add(1) as *const i64); assert_eq!(actual, expected, "assert_eq_int failed"); } unsafe extern "C" fn shim_assert_eq_float(args: *const *const u8, _ret: *mut u8) { - let actual = std::ptr::read(*args as *const f64); + let actual = std::ptr::read(*args as *const f64); let expected = std::ptr::read(*args.add(1) as *const f64); - let eps = std::ptr::read(*args.add(2) as *const f64); + let eps = std::ptr::read(*args.add(2) as *const f64); assert!( (actual - expected).abs() < eps, "assert_eq_float failed: |{} - {}| = {} >= eps {}", - actual, expected, (actual - expected).abs(), eps + actual, + expected, + (actual - expected).abs(), + eps ); } @@ -105,23 +111,26 @@ unsafe extern "C" fn shim_assert_eq_float(args: *const *const u8, _ret: *mut u8) fn prepare_with_shims(program: &mut BpProgram) { for instr in &mut program.instructions { - if let Instruction::Call { fn_ptr, node_type, .. } = instr { + if let Instruction::Call { + fn_ptr, node_type, .. + } = instr + { *fn_ptr = match node_type.as_str() { - "add" => shim_add as u64, - "subtract" => shim_subtract as u64, - "multiply" => shim_multiply as u64, - "divide" => shim_divide as u64, - "modulo" => shim_modulo as u64, - "greater_than" => shim_greater_than as u64, - "less_than" => shim_less_than as u64, - "abs" => shim_abs as u64, - "sqrt" => shim_sqrt as u64, - "lerp" => shim_lerp as u64, - "clamp" => shim_clamp as u64, - "print_string" => shim_print_string as u64, - "assert_true" => shim_assert_true as u64, - "assert_false" => shim_assert_false as u64, - "assert_eq_int" => shim_assert_eq_int as u64, + "add" => shim_add as u64, + "subtract" => shim_subtract as u64, + "multiply" => shim_multiply as u64, + "divide" => shim_divide as u64, + "modulo" => shim_modulo as u64, + "greater_than" => shim_greater_than as u64, + "less_than" => shim_less_than as u64, + "abs" => shim_abs as u64, + "sqrt" => shim_sqrt as u64, + "lerp" => shim_lerp as u64, + "clamp" => shim_clamp as u64, + "print_string" => shim_print_string as u64, + "assert_true" => shim_assert_true as u64, + "assert_false" => shim_assert_false as u64, + "assert_eq_int" => shim_assert_eq_int as u64, "assert_eq_float" => shim_assert_eq_float as u64, other => panic!("no shim for: {}", other), }; @@ -136,24 +145,57 @@ fn run(program: &mut BpProgram) { fn compile_and_run(graph: &GraphDescription) { let mut programs = compile_graph_to_bytecode(graph).expect("compile failed"); - for prog in &mut programs { run(prog); } + for prog in &mut programs { + run(prog); + } } // ── Graph builders ──────────────────────────────────────────────────────────── fn begin(pin: &str) -> NodeInstance { let mut n = NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); - n.outputs.push(PinInstance::new(pin, Pin::new(pin, "Body", DataType::Exec, PinType::Output))); + n.outputs.push(PinInstance::new( + pin, + Pin::new(pin, "Body", DataType::Exec, PinType::Output), + )); n } fn add_node(id: &str, ca: Option, cb: Option) -> NodeInstance { let mut n = NodeInstance::new(id, "add", Position { x: 100.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_a"), Pin::new(&format!("{id}_a"), "a", DataType::typed("i64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_b"), Pin::new(&format!("{id}_b"), "b", DataType::typed("i64"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_r"), Pin::new(&format!("{id}_r"), "result", DataType::typed("i64"), PinType::Output))); - if let Some(v) = ca { n.properties.insert(format!("{id}_a"), serde_json::json!(v)); } - if let Some(v) = cb { n.properties.insert(format!("{id}_b"), serde_json::json!(v)); } + n.inputs.push(PinInstance::new( + &format!("{id}_a"), + Pin::new( + &format!("{id}_a"), + "a", + DataType::typed("i64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_b"), + Pin::new( + &format!("{id}_b"), + "b", + DataType::typed("i64"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_r"), + Pin::new( + &format!("{id}_r"), + "result", + DataType::typed("i64"), + PinType::Output, + ), + )); + if let Some(v) = ca { + n.properties.insert(format!("{id}_a"), serde_json::json!(v)); + } + if let Some(v) = cb { + n.properties.insert(format!("{id}_b"), serde_json::json!(v)); + } n } @@ -195,77 +237,267 @@ fn get_bit_node(id: &str, value: i64, bit_index: i64) -> NodeInstance { fn mul_node(id: &str, ca: Option, cb: Option) -> NodeInstance { let mut n = NodeInstance::new(id, "multiply", Position { x: 100.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_a"), Pin::new(&format!("{id}_a"), "a", DataType::typed("i64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_b"), Pin::new(&format!("{id}_b"), "b", DataType::typed("i64"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_r"), Pin::new(&format!("{id}_r"), "result", DataType::typed("i64"), PinType::Output))); - if let Some(v) = ca { n.properties.insert(format!("{id}_a"), serde_json::json!(v)); } - if let Some(v) = cb { n.properties.insert(format!("{id}_b"), serde_json::json!(v)); } + n.inputs.push(PinInstance::new( + &format!("{id}_a"), + Pin::new( + &format!("{id}_a"), + "a", + DataType::typed("i64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_b"), + Pin::new( + &format!("{id}_b"), + "b", + DataType::typed("i64"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_r"), + Pin::new( + &format!("{id}_r"), + "result", + DataType::typed("i64"), + PinType::Output, + ), + )); + if let Some(v) = ca { + n.properties.insert(format!("{id}_a"), serde_json::json!(v)); + } + if let Some(v) = cb { + n.properties.insert(format!("{id}_b"), serde_json::json!(v)); + } n } fn gt_node(id: &str, cb: Option) -> NodeInstance { let mut n = NodeInstance::new(id, "greater_than", Position { x: 200.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_a"), Pin::new(&format!("{id}_a"), "a", DataType::typed("f64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_b"), Pin::new(&format!("{id}_b"), "b", DataType::typed("f64"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_r"), Pin::new(&format!("{id}_r"), "result", DataType::typed("bool"), PinType::Output))); - if let Some(v) = cb { n.properties.insert(format!("{id}_b"), serde_json::json!(v)); } + n.inputs.push(PinInstance::new( + &format!("{id}_a"), + Pin::new( + &format!("{id}_a"), + "a", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_b"), + Pin::new( + &format!("{id}_b"), + "b", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_r"), + Pin::new( + &format!("{id}_r"), + "result", + DataType::typed("bool"), + PinType::Output, + ), + )); + if let Some(v) = cb { + n.properties.insert(format!("{id}_b"), serde_json::json!(v)); + } n } fn branch_node(id: &str) -> NodeInstance { let mut n = NodeInstance::new(id, "branch", Position { x: 300.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_e"), Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_c"), Pin::new(&format!("{id}_c"), "condition", DataType::typed("bool"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_t"), Pin::new(&format!("{id}_t"), "True", DataType::Exec, PinType::Output))); - n.outputs.push(PinInstance::new(&format!("{id}_f"), Pin::new(&format!("{id}_f"), "False", DataType::Exec, PinType::Output))); + n.inputs.push(PinInstance::new( + &format!("{id}_e"), + Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_c"), + Pin::new( + &format!("{id}_c"), + "condition", + DataType::typed("bool"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_t"), + Pin::new(&format!("{id}_t"), "True", DataType::Exec, PinType::Output), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_f"), + Pin::new(&format!("{id}_f"), "False", DataType::Exec, PinType::Output), + )); n } fn getter_node(id: &str, var_name: &str) -> NodeInstance { - let mut n = NodeInstance::new(id, &format!("get_{}", var_name), Position { x: 100.0, y: 0.0 }); - n.outputs.push(PinInstance::new(&format!("{id}_r"), Pin::new(&format!("{id}_r"), "result", DataType::typed("i64"), PinType::Output))); + let mut n = NodeInstance::new( + id, + &format!("get_{}", var_name), + Position { x: 100.0, y: 0.0 }, + ); + n.outputs.push(PinInstance::new( + &format!("{id}_r"), + Pin::new( + &format!("{id}_r"), + "result", + DataType::typed("i64"), + PinType::Output, + ), + )); n } fn assert_eq_int_node(id: &str, expected: i64) -> NodeInstance { let mut n = NodeInstance::new(id, "assert_eq_int", Position { x: 400.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_e"), Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_a"), Pin::new(&format!("{id}_a"), "actual", DataType::typed("i64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_x"), Pin::new(&format!("{id}_x"), "expected", DataType::typed("i64"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_o"), Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output))); - n.properties.insert(format!("{id}_x"), serde_json::json!(expected as f64)); + n.inputs.push(PinInstance::new( + &format!("{id}_e"), + Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_a"), + Pin::new( + &format!("{id}_a"), + "actual", + DataType::typed("i64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_x"), + Pin::new( + &format!("{id}_x"), + "expected", + DataType::typed("i64"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_o"), + Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), + )); + n.properties + .insert(format!("{id}_x"), serde_json::json!(expected as f64)); n } fn assert_eq_float_node(id: &str, expected: f64, epsilon: f64) -> NodeInstance { let mut n = NodeInstance::new(id, "assert_eq_float", Position { x: 400.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_e"), Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_a"), Pin::new(&format!("{id}_a"), "actual", DataType::typed("f64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_x"), Pin::new(&format!("{id}_x"), "expected", DataType::typed("f64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_ep"), Pin::new(&format!("{id}_ep"), "epsilon", DataType::typed("f64"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_o"), Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output))); - n.properties.insert(format!("{id}_x"), serde_json::json!(expected)); - n.properties.insert(format!("{id}_ep"), serde_json::json!(epsilon)); + n.inputs.push(PinInstance::new( + &format!("{id}_e"), + Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_a"), + Pin::new( + &format!("{id}_a"), + "actual", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_x"), + Pin::new( + &format!("{id}_x"), + "expected", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_ep"), + Pin::new( + &format!("{id}_ep"), + "epsilon", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_o"), + Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), + )); + n.properties + .insert(format!("{id}_x"), serde_json::json!(expected)); + n.properties + .insert(format!("{id}_ep"), serde_json::json!(epsilon)); n } fn assert_true_node(id: &str) -> NodeInstance { let mut n = NodeInstance::new(id, "assert_true", Position { x: 400.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_e"), Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_c"), Pin::new(&format!("{id}_c"), "condition", DataType::typed("bool"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_o"), Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output))); + n.inputs.push(PinInstance::new( + &format!("{id}_e"), + Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_c"), + Pin::new( + &format!("{id}_c"), + "condition", + DataType::typed("bool"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_o"), + Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), + )); n } fn lerp_node(id: &str, ca: Option, cb: Option, ct: Option) -> NodeInstance { let mut n = NodeInstance::new(id, "lerp", Position { x: 100.0, y: 0.0 }); - n.inputs.push(PinInstance::new(&format!("{id}_a"), Pin::new(&format!("{id}_a"), "a", DataType::typed("f64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_b"), Pin::new(&format!("{id}_b"), "b", DataType::typed("f64"), PinType::Input))); - n.inputs.push(PinInstance::new(&format!("{id}_t"), Pin::new(&format!("{id}_t"), "t", DataType::typed("f64"), PinType::Input))); - n.outputs.push(PinInstance::new(&format!("{id}_r"), Pin::new(&format!("{id}_r"), "result", DataType::typed("f64"), PinType::Output))); - if let Some(v) = ca { n.properties.insert(format!("{id}_a"), serde_json::json!(v)); } - if let Some(v) = cb { n.properties.insert(format!("{id}_b"), serde_json::json!(v)); } - if let Some(v) = ct { n.properties.insert(format!("{id}_t"), serde_json::json!(v)); } + n.inputs.push(PinInstance::new( + &format!("{id}_a"), + Pin::new( + &format!("{id}_a"), + "a", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_b"), + Pin::new( + &format!("{id}_b"), + "b", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.inputs.push(PinInstance::new( + &format!("{id}_t"), + Pin::new( + &format!("{id}_t"), + "t", + DataType::typed("f64"), + PinType::Input, + ), + )); + n.outputs.push(PinInstance::new( + &format!("{id}_r"), + Pin::new( + &format!("{id}_r"), + "result", + DataType::typed("f64"), + PinType::Output, + ), + )); + if let Some(v) = ca { + n.properties.insert(format!("{id}_a"), serde_json::json!(v)); + } + if let Some(v) = cb { + n.properties.insert(format!("{id}_b"), serde_json::json!(v)); + } + if let Some(v) = ct { + n.properties.insert(format!("{id}_t"), serde_json::json!(v)); + } n } @@ -291,7 +523,9 @@ fn test_init_bytes_emitted_for_integer_constant() { g.add_connection(data("a", "a_r", "chk", "chk_a")); let programs = compile_graph_to_bytecode(&g).unwrap(); - let has_init = programs[0].instructions.iter() + let has_init = programs[0] + .instructions + .iter() .any(|i| matches!(i, Instruction::InitBytes { .. })); assert!(has_init, "should emit InitBytes for integer constants"); } @@ -306,7 +540,9 @@ fn test_init_bytes_emitted_for_float_constant() { g.add_connection(data("l", "l_r", "chk", "chk_a")); let programs = compile_graph_to_bytecode(&g).unwrap(); - let has_init = programs[0].instructions.iter() + let has_init = programs[0] + .instructions + .iter() .any(|i| matches!(i, Instruction::InitBytes { .. })); assert!(has_init, "should emit InitBytes for float constants"); } @@ -321,7 +557,10 @@ fn test_branch_emits_jumpif() { g.add_connection(data("gt", "gt_r", "br", "br_c")); let programs = compile_graph_to_bytecode(&g).unwrap(); - assert!(programs[0].instructions.iter().any(|i| matches!(i, Instruction::JumpIf { .. }))); + assert!(programs[0] + .instructions + .iter() + .any(|i| matches!(i, Instruction::JumpIf { .. }))); } #[test] @@ -334,9 +573,10 @@ fn test_node_type_embedded_in_call_instruction() { g.add_connection(data("a", "a_r", "chk", "chk_a")); let programs = compile_graph_to_bytecode(&g).unwrap(); - let has_add = programs[0].instructions.iter().any(|i| { - matches!(i, Instruction::Call { node_type, .. } if node_type == "add") - }); + let has_add = programs[0] + .instructions + .iter() + .any(|i| matches!(i, Instruction::Call { node_type, .. } if node_type == "add")); assert!(has_add, "should have a Call with node_type='add'"); } @@ -476,7 +716,8 @@ fn test_correct_branch_true_fires_assert_true() { let mut g = GraphDescription::new("t"); g.add_node(begin("be")); let mut gt = gt_node("gt", Some(5.0)); - gt.properties.insert("gt_a".to_string(), serde_json::json!(10.0)); + gt.properties + .insert("gt_a".to_string(), serde_json::json!(10.0)); g.add_node(gt); g.add_node(branch_node("br")); g.add_node(assert_true_node("at")); @@ -493,7 +734,8 @@ fn test_correct_branch_false_path_not_taken_for_true_condition() { let mut g = GraphDescription::new("t"); g.add_node(begin("be")); let mut gt = gt_node("gt", Some(0.0)); - gt.properties.insert("gt_a".to_string(), serde_json::json!(10.0)); + gt.properties + .insert("gt_a".to_string(), serde_json::json!(10.0)); g.add_node(gt); g.add_node(branch_node("br")); g.add_node(assert_true_node("at")); @@ -587,8 +829,11 @@ fn test_timing_vm_execute_1000_times() { for _ in 0..1_000 { pbgc::vm::run(&programs[0]).unwrap(); } - println!("[timing] 1000 × 10-node VM: {:?} ({:.2}µs/run)", - t.elapsed(), t.elapsed().as_micros() as f64 / 1000.0); + println!( + "[timing] 1000 × 10-node VM: {:?} ({:.2}µs/run)", + t.elapsed(), + t.elapsed().as_micros() as f64 / 1000.0 + ); } // ── Cycle detection ────────────────────────────────────────────────────────── @@ -614,8 +859,8 @@ fn pure_dependency_cycle_detected() { assert!(result.is_err(), "expected cyclic dependency error"); let err = result.unwrap_err(); assert!( - matches!(err, GraphyError::CyclicDependency) || - matches!(&err, GraphyError::Custom(msg) if msg.contains("Cyclic dependency")), + matches!(err, GraphyError::CyclicDependency) + || matches!(&err, GraphyError::Custom(msg) if msg.contains("Cyclic dependency")), "expected cycle error, got {:?}", err ); @@ -642,8 +887,8 @@ fn getter_with_multiple_consumers_emits_once() { // Connect both pure nodes through chained add to assert g.add_connection(data("a", "a_r", "chk", "chk_a")); - let programs = compile_graph_to_bytecode_with_variables(&g, vars) - .expect("compile with variables"); + let programs = + compile_graph_to_bytecode_with_variables(&g, vars).expect("compile with variables"); let loadvar_count = programs[0] .instructions .iter() @@ -656,12 +901,9 @@ fn getter_with_multiple_consumers_emits_once() { ); } - // ── Component ops (comp_get_prop / comp_set_prop / comp_call) ──────────────── -use pbgc::bytecode::comp_ops::{ - decode_name_blob, json_blob_len, CompOpKind, ComponentOpRef, -}; +use pbgc::bytecode::comp_ops::{decode_name_blob, json_blob_len, CompOpKind, ComponentOpRef}; use pbgc::bytecode::TypeSlot; use pbgc::compile_graph_to_bytecode_full; use std::cell::RefCell; @@ -676,11 +918,21 @@ fn comp_get_node(id: &str, class: &str, prop: &str) -> NodeInstance { // pin (#654); unconnected means self-targeted. n.inputs.push(PinInstance::new( "component_ref", - Pin::new("component_ref", "component", DataType::typed("ComponentRef"), PinType::Input), + Pin::new( + "component_ref", + "component", + DataType::typed("ComponentRef"), + PinType::Input, + ), )); n.outputs.push(PinInstance::new( &format!("{id}_r"), - Pin::new(&format!("{id}_r"), "value", DataType::typed("f64"), PinType::Output), + Pin::new( + &format!("{id}_r"), + "value", + DataType::typed("f64"), + PinType::Output, + ), )); n } @@ -697,11 +949,21 @@ fn comp_set_node(id: &str, class: &str, prop: &str, value: Option) -> NodeI )); n.inputs.push(PinInstance::new( "component_ref", - Pin::new("component_ref", "component", DataType::typed("ComponentRef"), PinType::Input), + Pin::new( + "component_ref", + "component", + DataType::typed("ComponentRef"), + PinType::Input, + ), )); n.inputs.push(PinInstance::new( &format!("{id}_v"), - Pin::new(&format!("{id}_v"), "value", DataType::typed("f64"), PinType::Input), + Pin::new( + &format!("{id}_v"), + "value", + DataType::typed("f64"), + PinType::Input, + ), )); n.outputs.push(PinInstance::new( &format!("{id}_o"), @@ -725,7 +987,12 @@ fn comp_call_node(id: &str, class: &str, method: &str, with_return: bool) -> Nod )); n.inputs.push(PinInstance::new( "component_ref", - Pin::new("component_ref", "component", DataType::typed("ComponentRef"), PinType::Input), + Pin::new( + "component_ref", + "component", + DataType::typed("ComponentRef"), + PinType::Input, + ), )); n.outputs.push(PinInstance::new( &format!("{id}_o"), @@ -734,7 +1001,12 @@ fn comp_call_node(id: &str, class: &str, method: &str, with_return: bool) -> Nod if with_return { n.outputs.push(PinInstance::new( &format!("{id}_r"), - Pin::new(&format!("{id}_r"), "result", DataType::typed("f64"), PinType::Output), + Pin::new( + &format!("{id}_r"), + "result", + DataType::typed("f64"), + PinType::Output, + ), )); } n @@ -786,13 +1058,20 @@ fn comp_get_feeding_comp_set_compiles_as_blob_chain() { let prog = &compiled.programs[0]; // The set's value input resolves to the get's reserved output slot. let get_out = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { node_type, output_offset, has_output: true, .. } - if node_type == "comp_get_prop::Light::intensity" => Some(*output_offset), + Instruction::Call { + node_type, + output_offset, + has_output: true, + .. + } if node_type == "comp_get_prop::Light::intensity" => Some(*output_offset), _ => None, }); let set_in = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { node_type, input_offsets, .. } - if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets[1]), + Instruction::Call { + node_type, + input_offsets, + .. + } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets[1]), _ => None, }); assert_eq!(get_out, set_in, "get output slot feeds set value input"); @@ -806,11 +1085,13 @@ fn comp_call_without_return_has_no_output_slot() { g.add_connection(exec("begin", "be", "call", "call_e")); let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - let has_output = compiled.programs[0].instructions.iter().any(|i| matches!( - i, - Instruction::Call { node_type, has_output: true, .. } - if node_type == "comp_call::Door::open" - )); + let has_output = compiled.programs[0].instructions.iter().any(|i| { + matches!( + i, + Instruction::Call { node_type, has_output: true, .. } + if node_type == "comp_call::Door::open" + ) + }); assert!(!has_output, "void call must not reserve an output slot"); } @@ -843,8 +1124,10 @@ unsafe fn log_op(kind: &str, name_ptr: *const u8, value_ptr: Option<*const u8>) String::from_utf8_lossy(bytes).into_owned() }); COMP_OPS_LOG.with(|log| { - log.borrow_mut() - .push(format!("{kind}:{class}:{member}:{}", value.unwrap_or_default())) + log.borrow_mut().push(format!( + "{kind}:{class}:{member}:{}", + value.unwrap_or_default() + )) }); } @@ -866,7 +1149,10 @@ unsafe extern "C" fn stub_comp_call(args: *const *const u8, ret: *mut u8, _ts: * fn prepare_comp_shims(program: &mut BpProgram) { for instr in &mut program.instructions { - if let Instruction::Call { fn_ptr, node_type, .. } = instr { + if let Instruction::Call { + fn_ptr, node_type, .. + } = instr + { if node_type.starts_with("comp_get_prop::") { *fn_ptr = stub_comp_get as u64; } else if node_type.starts_with("comp_set_prop::") { @@ -924,10 +1210,13 @@ fn comp_get_result_flows_into_set_through_arena() { } let log = COMP_OPS_LOG.with(|log| log.borrow().clone()); - assert_eq!(log, vec![ - "get:Light:intensity:".to_string(), - "set:Light:intensity:42.5".to_string(), - ]); + assert_eq!( + log, + vec![ + "get:Light:intensity:".to_string(), + "set:Light:intensity:42.5".to_string(), + ] + ); } // ── Rust-source emission (#651) ─────────────────────────────────────────────── @@ -1006,12 +1295,22 @@ fn get_ref_node(id: &str, class: &str, index: u32, with_actor_pin: bool) -> Node if with_actor_pin { n.inputs.push(PinInstance::new( "actor", - Pin::new("actor", "actor", DataType::typed("ActorRef"), PinType::Input), + Pin::new( + "actor", + "actor", + DataType::typed("ActorRef"), + PinType::Input, + ), )); } n.outputs.push(PinInstance::new( &format!("{id}_r"), - Pin::new(&format!("{id}_r"), "component", DataType::typed("ComponentRef"), PinType::Output), + Pin::new( + &format!("{id}_r"), + "component", + DataType::typed("ComponentRef"), + PinType::Output, + ), )); n } @@ -1021,13 +1320,24 @@ fn find_node(id: &str, needle: &str) -> NodeInstance { let mut n = NodeInstance::new(id, "find_object_by_name", Position { x: 25.0, y: 0.0 }); n.inputs.push(PinInstance::new( &format!("{id}_n"), - Pin::new(&format!("{id}_n"), "name", DataType::typed("String"), PinType::Input), + Pin::new( + &format!("{id}_n"), + "name", + DataType::typed("String"), + PinType::Input, + ), )); n.outputs.push(PinInstance::new( &format!("{id}_r"), - Pin::new(&format!("{id}_r"), "actor", DataType::typed("ActorRef"), PinType::Output), + Pin::new( + &format!("{id}_r"), + "actor", + DataType::typed("ActorRef"), + PinType::Output, + ), )); - n.properties.insert(format!("{id}_n"), serde_json::json!(needle)); + n.properties + .insert(format!("{id}_n"), serde_json::json!(needle)); n } @@ -1045,11 +1355,11 @@ fn pin_targeted_comp_set_stages_reference_operand() { let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); let prog = &compiled.programs[0]; let call = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { node_type, input_offsets, .. } - if node_type == "comp_set_prop::Light::intensity" => - { - Some(input_offsets.clone()) - } + Instruction::Call { + node_type, + input_offsets, + .. + } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets.clone()), _ => None, }); let input_offsets = call.expect("pin-targeted set must compile to a Call"); @@ -1063,13 +1373,16 @@ fn pin_targeted_comp_set_stages_reference_operand() { } _ => None, }); - let fields = - decode_targeted_name_blob(&blob.expect("name blob staged")).expect("decodes"); + let fields = decode_targeted_name_blob(&blob.expect("name blob staged")).expect("decodes"); assert_eq!(fields.target, pbgc::bytecode::comp_ops::RefTarget::RefPin); assert_eq!( compiled.components, vec![ - ComponentOpRef { kind: CompOpKind::GetRef, class_name: "Light".into(), member: "0".into() }, + ComponentOpRef { + kind: CompOpKind::GetRef, + class_name: "Light".into(), + member: "0".into() + }, ComponentOpRef { kind: CompOpKind::SetProp, class_name: "Light".into(), @@ -1117,22 +1430,22 @@ fn cross_object_chain_compiles_as_identity_producers() { // The get op is pin-targeted (ref wired into its component_ref pin): // name + reference operand. let get_inputs = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { node_type, input_offsets, .. } - if node_type == "comp_get_prop::Light::intensity" => - { - Some(input_offsets.len()) - } + Instruction::Call { + node_type, + input_offsets, + .. + } if node_type == "comp_get_prop::Light::intensity" => Some(input_offsets.len()), _ => None, }); assert_eq!(get_inputs, Some(2)); // The set stays self-targeted (its own component_ref pin unconnected): // legacy name + value shape. let set_inputs = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { node_type, input_offsets, .. } - if node_type == "comp_set_prop::Light::intensity" => - { - Some(input_offsets.len()) - } + Instruction::Call { + node_type, + input_offsets, + .. + } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets.len()), _ => None, }); assert_eq!(set_inputs, Some(2)); @@ -1181,7 +1494,12 @@ fn rust_emission_routes_references_through_script_refs() { let mut literal = NodeInstance::new("lit", "object_ref_literal", Position { x: 10.0, y: 0.0 }); literal.outputs.push(PinInstance::new( "lit_r", - Pin::new("lit_r", "component", DataType::typed("ComponentRef"), PinType::Output), + Pin::new( + "lit_r", + "component", + DataType::typed("ComponentRef"), + PinType::Output, + ), )); literal .properties @@ -1221,22 +1539,28 @@ fn unconnected_pins_carry_explicit_self_target() { let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); let prog = &compiled.programs[0]; - let inputs = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { node_type, input_offsets, .. } - if node_type == "comp_set_prop::Light::intensity" => - { - Some(input_offsets.clone()) - } - _ => None, - }) - .expect("set compiles"); + let inputs = prog + .instructions + .iter() + .find_map(|i| match i { + Instruction::Call { + node_type, + input_offsets, + .. + } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets.clone()), + _ => None, + }) + .expect("set compiles"); assert_eq!(inputs.len(), 2, "self-targeted: name + value only"); let blob = prog.instructions.iter().find_map(|i| match i { Instruction::InitBytes { offset, bytes } if *offset == inputs[0] => Some(bytes.clone()), _ => None, }); let fields = decode_targeted_name_blob(blob.as_deref().unwrap()).expect("decodes"); - assert_eq!(fields.target, pbgc::bytecode::comp_ops::RefTarget::SelfActor); + assert_eq!( + fields.target, + pbgc::bytecode::comp_ops::RefTarget::SelfActor + ); // The staged bytes really do end with the `self` field. let blob = blob.unwrap(); assert!(blob.ends_with(b"self\0")); @@ -1255,13 +1579,18 @@ fn pin_targeted_call_stages_argc_and_target() { let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); let prog = &compiled.programs[0]; - let inputs = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { node_type, input_offsets, .. } if node_type == "comp_call::Door::open" => { - Some(input_offsets.clone()) - } - _ => None, - }) - .expect("call compiles"); + let inputs = prog + .instructions + .iter() + .find_map(|i| match i { + Instruction::Call { + node_type, + input_offsets, + .. + } if node_type == "comp_call::Door::open" => Some(input_offsets.clone()), + _ => None, + }) + .expect("call compiles"); // name + reference operand (no method args on this node). assert_eq!(inputs.len(), 2); let blob = prog.instructions.iter().find_map(|i| match i { From 81087641602edfdaf090078ae066caf80ea5fbd1 Mon Sep 17 00:00:00 2001 From: Tristan Poland <34868944+tristanpoland@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:24:03 -0400 Subject: [PATCH 2/2] Reduce PBGC to the pulsar_std node catalogue (0.2.0) Removes the bytecode compiler and VM and the graph-to-Rust backend: Blueprint graphs compile to script modules (blueprint_compiler) and the Rust export is generated from them (pulsar_script_codegen). Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 107 -- Cargo.toml | 16 +- README.md | 60 +- src/bytecode/comp_ops.rs | 491 ---------- src/bytecode/mod.rs | 103 -- src/codegen/bytecode_codegen.rs | 1629 ------------------------------- src/codegen/mod.rs | 11 - src/codegen/node_handlers.rs | 9 - src/codegen/rust_codegen.rs | 1376 -------------------------- src/compiler.rs | 379 ------- src/disk.rs | 234 ----- src/lib.rs | 51 +- src/metadata.rs | 26 - src/project.rs | 913 ----------------- src/vm/mod.rs | 226 ----- tests/bytecode_tests.rs | 1603 ------------------------------ tests/metadata.rs | 27 + 17 files changed, 67 insertions(+), 7194 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 src/bytecode/comp_ops.rs delete mode 100644 src/bytecode/mod.rs delete mode 100644 src/codegen/bytecode_codegen.rs delete mode 100644 src/codegen/mod.rs delete mode 100644 src/codegen/node_handlers.rs delete mode 100644 src/codegen/rust_codegen.rs delete mode 100644 src/compiler.rs delete mode 100644 src/disk.rs delete mode 100644 src/project.rs delete mode 100644 src/vm/mod.rs delete mode 100644 tests/bytecode_tests.rs create mode 100644 tests/metadata.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index a24fa4c..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,107 +0,0 @@ -# PBGC Architecture - -## Overview - -PBGC (Pulsar Blueprint Graph Compiler) is built in two layers: - -``` -┌─────────────────────────────────────┐ -│ Pulsar-Native (Engine) │ -│ ┌───────────────────────────────┐ │ -│ │ blueprint_compiler crate │ │ -│ │ (thin wrapper for engine) │ │ -│ └─────────────┬─────────────────┘ │ -│ │ │ -│ ┌───────────▼────────────┐ │ -│ │ pulsar_std │ │ -│ │ (node definitions) │ │ -│ └────────────────────────┘ │ -└─────────────────┬───────────────────┘ - │ -┌─────────────────▼───────────────────┐ -│ PBGC (Blueprint) │ -│ - pulsar_std integration │ -│ - Rust code generation │ -│ - Blueprint-specific nodes │ -└─────────────────┬───────────────────┘ - │ -┌─────────────────▼───────────────────┐ -│ Graphy (General) │ -│ - Graph data structures │ -│ - Data flow analysis │ -│ - Execution flow analysis │ -│ - AST transformation │ -└─────────────────────────────────────┘ -``` - -## Module Structure - -### PBGC Modules - -- **`lib.rs`** - Public API and re-exports -- **`metadata.rs`** - pulsar_std integration -- **`compiler.rs`** - Main compilation entry points -- **`codegen/`** - Rust code generation - - `rust_codegen.rs` - Blueprint → Rust generator - - `node_handlers.rs` - Special node handling - -### Graphy Modules - -- **`core/`** - Data structures - - `graph.rs`, `node.rs`, `connection.rs`, `types.rs`, `metadata.rs` -- **`analysis/`** - Analysis passes - - `data_flow.rs` - Dependency resolution - - `exec_flow.rs` - Execution routing -- **`generation/`** - Code generation framework - - `context.rs`, `strategies.rs` -- **`utils/`** - Utilities - - `ast_transform.rs` - AST manipulation - - `variable_gen.rs` - Variable naming - - `subgraph_expander.rs` - Graph composition - -## Compilation Pipeline - -1. **Load Metadata** - Extract from pulsar_std -2. **Expand Sub-graphs** - Inline macros (optional) -3. **Data Flow Analysis** - Build dependency graph (Graphy) -4. **Execution Flow** - Map exec connections (Graphy) -5. **Code Generation** - Generate Rust code (PBGC) - -## Key Design Decisions - -### Repository Organization - -**Pulsar-Native (Engine Repository)**: -- `crates/pulsar_std` - Standard library with node definitions -- `crates/pulsar_macros` - Macro support for node registration -- `crates/blueprint_compiler` - Thin wrapper that re-exports PBGC for engine use - -**PBGC (Compiler Repository)**: -- Imports `pulsar_std` from Pulsar-Native via path dependency -- Provides Blueprint-specific code generation on top of Graphy -- Can be used standalone or through the engine's wrapper - -**Graphy (Analysis Library Repository)**: -- General-purpose graph compilation library -- No Blueprint or engine-specific code -- Reusable for other graph compilation tasks (e.g., PSGC shader compiler) - -### Why Separate Graphy? - -- **Reusability**: PSGC shader compiler uses same infrastructure -- **Modularity**: Clear separation of general vs specific -- **Testing**: Can test graph analysis independently -- **Extensibility**: Easy to add new target languages - -### What's General vs Blueprint-Specific? - -**Graphy (General)**: -- Graph representation -- Analysis algorithms -- AST utilities - -**PBGC (Blueprint-Specific)**: -- pulsar_std integration -- Rust code generation -- Variable Cell/RefCell wrappers -- Blueprint naming conventions diff --git a/Cargo.toml b/Cargo.toml index 4d31969..1a59abc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,17 @@ [package] name = "pbgc" -version = "0.1.0" +version = "0.2.0" edition = "2021" authors = ["Pulsar Team"] -description = "Pulsar Blueprint Graph Compiler - Production-ready compiler for Blueprint visual scripts" +description = "The pulsar_std Blueprint node catalogue as Graphy node metadata (palettes, pin shapes, graph validation)" license = "MIT" [dependencies] -# General graph compilation library +# General graph library whose NodeMetadata this crate fills. graphy = { git = "https://github.com/Far-Beyond-Pulsar/Graphy", rev = "c43924070aa0955302f8015708625be279da77a7" } # Pulsar standard library integration pulsar_std = { git = "https://github.com/Far-Beyond-Pulsar/Pulsar-Native", rev = "38bbbd93a5986c43955af8879380ee544025abbb" } -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - # Logging tracing = "0.1" - -# Error handling -thiserror = "1.0" - -[dev-dependencies] -tracing-subscriber = "0.3" diff --git a/README.md b/README.md index a918fb8..6b5bf8a 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,31 @@ -# PBGC - Pulsar Blueprint Graph Compiler +# PBGC: pulsar_std node catalogue for Graphy -Compiler for Pulsar Blueprint visual scripts to Rust code. - -## Features - -- Compiles Blueprint node graphs to executable Rust code -- Integrates with pulsar_std node registry -- Thread-safe variable handling (Cell/RefCell + Arc) -- Drop-in replacement for existing compiler - -## Quick Start +PBGC bridges `pulsar_std`'s `#[blueprint]` node registry and +[Graphy](https://github.com/Far-Beyond-Pulsar/Graphy)'s node metadata. The +Blueprint editor reads the standard library's nodes through it for its palette, +pin shapes and graph validation. ```rust -use pbgc::compile_graph; -use graphy::GraphDescription; - -let graph = GraphDescription::new("my_blueprint"); -// ... build graph with nodes and connections - -match compile_graph(&graph) { - Ok(rust_code) => std::fs::write("generated.rs", rust_code)?, - Err(e) => eprintln!("Error: {}", e), -} +let metadata = pbgc::extract_node_metadata()?; // every pulsar_std node +let provider = pbgc::BlueprintMetadataProvider::new(); // for graphy::DataResolver ``` -## Integration with Pulsar Engine +## What was removed (0.2.0) -Replace existing compiler calls: +PBGC used to compile Blueprint graphs itself. That is gone: -```rust -// OLD: -use pulsar_engine::compiler::compile_graph; - -// NEW: -use pbgc::compile_graph; - -// API is identical - drop-in replacement! -``` +| Removed | Replacement | +|---|---| +| `compile_graph`, `compile_graph_with_variables`, `compile_graph_with_library_manager` (graph to Rust source) | Compile to a script module with `blueprint_compiler`, then export with `pulsar_script_codegen::actor::generate_actor` | +| `compile_graph_to_bytecode*`, `BpProgram`, `Instruction`, `vm::run`, `VmError` (the old bytecode and its VM) | `pulsar_script_vm`: `Module`, `Program`, `Vm` | +| `generate_project`, `generate_blueprint_actor_source*`, `ProjectSpec`, `CompiledBlueprint` | `pulsar_script_codegen::actor::{generate_actor, class_files}` | +| `compile_project`, `compile_project_generated` | compile each class with `blueprint_compiler`, export with `class_files` | +| `BlueprintMetadataProvider::param_layout` / `return_layout` (bytecode layout helpers) | none: the script VM is typed, not layout-based | +| Graphy re-exports (`pbgc::GraphDescription`, ...) | depend on `graphy` directly | -## Architecture +The Rust export no longer keeps class variables in thread-local statics shared +by every instance of a class, and no longer sleeps the thread for `delay`: each +actor owns its script state and waits are suspended calls. -Built on [Graphy](https://github.com/Far-Beyond-Pulsar/Graphy) library with Blueprint-specific: -- pulsar_std integration -- Rust code generation -- Variable getter/setter handling -- Event node support +Anything outside the Pulsar-Native repository that still calls the removed +APIs must move to the replacements above; this is a breaking release. diff --git a/src/bytecode/comp_ops.rs b/src/bytecode/comp_ops.rs deleted file mode 100644 index 7e55347..0000000 --- a/src/bytecode/comp_ops.rs +++ /dev/null @@ -1,491 +0,0 @@ -//! Component-operation bytecode ABI. -//! -//! Blueprint graphs address engine components through three node kinds the -//! editor generates from reflection metadata: -//! -//! ```text -//! comp_get_prop::{Class}::{Member} pure — produces a value -//! comp_set_prop::{Class}::{Member} exec — consumes a value -//! comp_call::{Class}::{Method} exec — optional return -//! ``` -//! -//! None of these have `pulsar_std` registry entries, so the generic -//! `__bp_dispatch_` dlsym path cannot resolve them. Instead every kind -//! compiles to a plain [`Instruction::Call`] whose `node_type` retains the -//! full original string (e.g. `comp_set_prop::Light::Intensity`); the -//! executor routes on that prefix BEFORE dlsym lookup and hands the call to -//! a component-op handler instead of a native shim. -//! -//! # Arena calling convention -//! -//! All operands are staged in the arena by `InitBytes`, exactly like -//! constants: -//! -//! * **Name blob** — always `input_offsets[0]`: -//! `{class}\0{member}\0` as UTF-8. Deduplicated per event by the codegen. -//! * **Value blobs** — JSON values length-prefixed with a little-endian -//! `u64`: `[u64 len][len bytes of UTF-8 JSON]`. Constants stage their -//! exact bytes; runtime-produced outputs reserve -//! [`JSON_BLOB_CAPACITY`] bytes of arena. -//! -//! Per kind: -//! -//! | Kind | inputs | output | -//! |-----------|---------------------------------------|-----------------| -//! | GetProp | `[name]` | value blob slot | -//! | SetProp | `[name, value]` | — | -//! | Call | `[name, arg0, .. argN]` | blob slot iff the graph uses the return | -//! -//! Values travel as JSON blobs at this layer so the format stays stable -//! regardless of the concrete property type; the live handler (#647) decodes -//! them through `pulsar_world_registry::marshal` against registry metadata. -//! Native (non-component) sources connected to component pins are rejected -//! at compile time until that handler exists. -//! -//! # Identity operands (#654) -//! -//! Component ops may address an actor OTHER than the one the running -//! instance is bound to: a `component_ref` input pin connected to an -//! identity producer (`get_component_ref::`, `find_object_by_*`, -//! `object_ref_literal`) carries the target explicitly. The staged ABI -//! extends accordingly: -//! -//! * **Target field** — the name blob carries a trailing NUL-terminated -//! field naming the addressing mode ([`encode_targeted_name_blob`], -//! [`encode_targeted_call_name_blob`]): `self` (address the executing -//! instance's own entity — the legacy behaviour) or `pin` (take the -//! target from the reference operand). ABI v2 compilers emit the field -//! UNCONDITIONALLY so the runtime reader can scan a fixed field count -//! without any length side-channel; the decoders still accept the legacy -//! shorter shapes ([`RefTarget::SelfActor`] implied) for hand-built -//! programs and tests. -//! * **Reference operand** — a pin-targeted op stages ONE extra JSON blob -//! immediately after the name blob (before the value arguments), -//! holding the reference in its #642 marshalling shape: a ComponentRef -//! object (`{"entity":bits,"class_name":…,"component_index":n}`) or a -//! bare packed-bits number for an ActorRef. -//! -//! Identity producers themselves are ordinary pure ops routed by the same -//! node-type prefixes, with JSON-blob operands and no name blob: -//! -//! | Node type | inputs | output | -//! |----------------------------|----------------------|---------------------| -//! | `get_component_ref::C::N` | `[name, (actor)]` | ComponentRef blob | -//! | `find_object_by_stable_id` | `[needle]` | ActorRef bits blob | -//! | `find_object_by_name` | `[needle]` | ActorRef bits blob | -//! | `object_ref_literal` | `[literal]` | ComponentRef blob | -//! -//! `get_component_ref` keeps the name-blob convention (its class/index ride -//! there); the others stage everything as their single JSON operand. All -//! resolution happens at RUNTIME against the live world (#639 lazy -//! resolution) — literals stage their `{stable_id, …}` save/load form, not -//! entity bits, so references survive reloads. - -use serde::{Deserialize, Serialize}; - -/// Reserved arena bytes for any component-op output/argument value blob -/// written at runtime (length prefix included). Compile-time constants use -/// only their exact size. -pub const JSON_BLOB_CAPACITY: usize = 4096; - -/// How a component op addresses the actor it operates on (#654). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RefTarget { - /// The executing instance's own bound entity (the pre-#654 behaviour). - SelfActor, - /// The reference carried by the op's staged reference operand. - RefPin, -} - -impl RefTarget { - /// The name-blob field spelling of this mode. - pub fn as_field(self) -> &'static str { - match self { - RefTarget::SelfActor => "self", - RefTarget::RefPin => "pin", - } - } - - /// Parse a name-blob target field. `None` for anything else — callers - /// refuse rather than guess. - pub fn from_field(field: &str) -> Option { - match field { - "self" => Some(RefTarget::SelfActor), - "pin" => Some(RefTarget::RefPin), - _ => None, - } - } -} - -/// Which reflected member a component node addresses. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CompOpKind { - GetProp, - SetProp, - Call, - /// Pure producer of a `ComponentRef` for `(actor, class, index)` - /// (`get_component_ref::{Class}::{Index}`). - GetRef, - /// Pure resolver: StableId needle → `ActorRef` (#654). - FindByStableId, - /// Pure resolver: display-name needle → `ActorRef` (#654). - FindByName, - /// Pure resolver: staged serialized reference → resolved `ComponentRef` - /// (`object_ref_literal`, the drag-a-scene-object literal node). - ObjectLiteral, -} - -impl CompOpKind { - /// The node-type prefix this kind is encoded under. - pub fn prefix(self) -> &'static str { - match self { - CompOpKind::GetProp => "comp_get_prop::", - CompOpKind::SetProp => "comp_set_prop::", - CompOpKind::Call => "comp_call::", - CompOpKind::GetRef => "get_component_ref::", - CompOpKind::FindByStableId => "find_object_by_stable_id", - CompOpKind::FindByName => "find_object_by_name", - CompOpKind::ObjectLiteral => "object_ref_literal", - } - } - - /// Whether ops of this kind stage a class/member name blob. - /// - /// The identity resolvers (`find_*` / `object_ref_literal`) carry all - /// their data in plain JSON operands instead. - pub fn uses_name_blob(self) -> bool { - !matches!( - self, - CompOpKind::FindByStableId | CompOpKind::FindByName | CompOpKind::ObjectLiteral - ) - } -} - -/// One `(class, member)` pair referenced by a compiled program. The runtime -/// uses these to validate availability and to pre-resolve reflection -/// metadata before executing events. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ComponentOpRef { - pub kind: CompOpKind, - pub class_name: String, - pub member: String, -} - -/// Parse a full node-type string into its operation parts. -/// -/// Returns `None` for anything without a `comp_*::` / identity-op prefix — -/// callers treat that as "not a component op" and fall through to normal -/// dispatch. Identity resolvers carry no class/member in the node type, so -/// their class/member fields come back empty. -pub fn parse_node_type(node_type: &str) -> Option<(CompOpKind, &str, &str)> { - let (kind, rest) = if let Some(r) = node_type.strip_prefix("comp_get_prop::") { - (CompOpKind::GetProp, r) - } else if let Some(r) = node_type.strip_prefix("comp_set_prop::") { - (CompOpKind::SetProp, r) - } else if let Some(r) = node_type.strip_prefix("comp_call::") { - (CompOpKind::Call, r) - } else if let Some(r) = node_type.strip_prefix("get_component_ref::") { - (CompOpKind::GetRef, r) - } else if node_type == "find_object_by_stable_id" { - (CompOpKind::FindByStableId, "") - } else if node_type == "find_object_by_name" { - (CompOpKind::FindByName, "") - } else if node_type == "object_ref_literal" { - (CompOpKind::ObjectLiteral, "") - } else { - return None; - }; - - let (class_name, member) = match rest.split_once("::") { - Some(pair) => pair, - None => (rest, ""), - }; - if kind.uses_name_blob() && (class_name.is_empty() || member.is_empty()) { - return None; - } - Some((kind, class_name, member)) -} - -/// Encode a name blob: `{class}\0{member}\0`. -pub fn encode_name_blob(class_name: &str, member: &str) -> Vec { - let mut bytes = Vec::with_capacity(class_name.len() + member.len() + 2); - bytes.extend_from_slice(class_name.as_bytes()); - bytes.push(0); - bytes.extend_from_slice(member.as_bytes()); - bytes.push(0); - bytes -} - -/// Encode a `comp_call` name blob, staging the argument count as a third -/// NUL-terminated field so the runtime handler knows how many value -/// operands follow without any out-of-band length: `{class}\0{method}\ -/// {arg_count}\0`. -pub fn encode_call_name_blob(class_name: &str, method_name: &str, arg_count: usize) -> Vec { - let mut bytes = encode_name_blob(class_name, method_name); - bytes.extend_from_slice(arg_count.to_string().as_bytes()); - bytes.push(0); - bytes -} - -/// Encode a name blob with a trailing target-mode field (#654 ABI): -/// `{class}\0{member}\0{target}\0`. -pub fn encode_targeted_name_blob(class_name: &str, member: &str, target: RefTarget) -> Vec { - let mut bytes = encode_name_blob(class_name, member); - bytes.extend_from_slice(target.as_field().as_bytes()); - bytes.push(0); - bytes -} - -/// Encode a `comp_call` name blob with argument count AND target mode -/// (#654 ABI): `{class}\0{method}\0{arg_count}\0{target}\0`. -pub fn encode_targeted_call_name_blob( - class_name: &str, - method_name: &str, - arg_count: usize, - target: RefTarget, -) -> Vec { - let mut bytes = encode_call_name_blob(class_name, method_name, arg_count); - bytes.extend_from_slice(target.as_field().as_bytes()); - bytes.push(0); - bytes -} - -/// A decoded get/set name blob: the addressed member plus how the target -/// actor is chosen (#654). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NameFields { - pub class_name: String, - pub member: String, - pub target: RefTarget, -} - -/// Decode a get/set name blob written by [`encode_name_blob`] (legacy, -/// target = [`RefTarget::SelfActor`]) or [`encode_targeted_name_blob`]. -/// -/// Discrimination is by NUL-field count, which is authoritative because -/// names cannot contain NULs. -pub fn decode_targeted_name_blob(blob: &[u8]) -> Option { - let parts = split_nul_fields(blob)?; - match parts.len() { - 2 => Some(NameFields { - class_name: parts[0].to_string(), - member: parts[1].to_string(), - target: RefTarget::SelfActor, - }), - 3 => Some(NameFields { - class_name: parts[0].to_string(), - member: parts[1].to_string(), - target: RefTarget::from_field(parts[2])?, - }), - _ => None, - } -} - -/// A decoded call name blob: the addressed method, its declared argument -/// count, and how the target actor is chosen (#654). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CallNameFields { - pub class_name: String, - pub method: String, - pub arg_count: usize, - pub target: RefTarget, -} - -/// Decode a `comp_call` name blob written by [`encode_call_name_blob`] -/// (legacy, target = [`RefTarget::SelfActor`]) or -/// [`encode_targeted_call_name_blob`]. Field-count discriminated, like the -/// get/set decoder. -pub fn decode_targeted_call_name_blob(blob: &[u8]) -> Option { - let parts = split_nul_fields(blob)?; - match parts.len() { - 3 => Some(CallNameFields { - class_name: parts[0].to_string(), - method: parts[1].to_string(), - arg_count: parts[2].parse().ok()?, - target: RefTarget::SelfActor, - }), - 4 => Some(CallNameFields { - class_name: parts[0].to_string(), - method: parts[1].to_string(), - arg_count: parts[2].parse().ok()?, - target: RefTarget::from_field(parts[3])?, - }), - _ => None, - } -} - -/// Split a staged NUL-separated blob into its UTF-8 fields (trailing -/// terminator required, interior NULs are separators). -fn split_nul_fields(blob: &[u8]) -> Option> { - let text = std::str::from_utf8(blob).ok()?; - let text = text.strip_suffix('\0')?; - Some(text.split('\0').collect()) -} - -/// Decode a legacy 2-field name blob previously written by -/// [`encode_name_blob`]. -/// -/// `blob` spans exactly the staged region; interior NULs are separators. -pub fn decode_name_blob(blob: &[u8]) -> Option<(&str, &str)> { - let parts = split_nul_fields(blob)?; - match parts.as_slice() { - [class_name, member] => Some((class_name, member)), - _ => None, - } -} - -/// Encode a JSON value blob: little-endian `u64` length prefix + UTF-8 bytes. -pub fn encode_json_blob(json: &str) -> Vec { - let mut bytes = Vec::with_capacity(8 + json.len()); - bytes.extend_from_slice(&(json.len() as u64).to_le_bytes()); - bytes.extend_from_slice(json.as_bytes()); - bytes -} - -/// Read the length prefix of a runtime-written JSON blob. -/// -/// # Safety -/// `ptr` must point to at least 8 readable, 8-aligned bytes. -pub unsafe fn json_blob_len(ptr: *const u8) -> usize { - usize::try_from(std::ptr::read(ptr as *const u64)).unwrap_or(0) -} - -/// Write `json` into an arena-reserved blob slot. -/// -/// # Safety -/// `ptr` must point to at least [`JSON_BLOB_CAPACITY`] writable bytes. -pub unsafe fn write_json_blob(ptr: *mut u8, json: &str) { - debug_assert!( - json.len() + 8 <= JSON_BLOB_CAPACITY, - "component-op JSON blob exceeds reserved capacity" - ); - std::ptr::write(ptr as *mut u64, json.len() as u64); - std::ptr::copy_nonoverlapping(json.as_ptr(), ptr.add(8), json.len()); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_all_three_kinds() { - assert_eq!( - parse_node_type("comp_get_prop::Light::intensity"), - Some((CompOpKind::GetProp, "Light", "intensity")) - ); - assert_eq!( - parse_node_type("comp_set_prop::Light::color"), - Some((CompOpKind::SetProp, "Light", "color")) - ); - assert_eq!( - parse_node_type("comp_call::Door::open"), - Some((CompOpKind::Call, "Door", "open")) - ); - } - - #[test] - fn rejects_malformed_node_types() { - assert_eq!(parse_node_type("add"), None); - assert_eq!(parse_node_type("comp_get_prop::OnlyClass"), None); - assert_eq!(parse_node_type("comp_set_prop::::prop"), None); - assert_eq!(parse_node_type("comp_call::Class::"), None); - } - - #[test] - fn name_blobs_round_trip() { - let bytes = encode_name_blob("Light", "intensity"); - assert_eq!(decode_name_blob(&bytes), Some(("Light", "intensity"))); - // Names containing NUL would corrupt the encoding; parse refuses. - let evil = encode_name_blob("Li\0ght", "x"); - assert_ne!(decode_name_blob(&evil), Some(("Li\0ght", "x"))); - } - - #[test] - fn json_blobs_round_trip() { - let encoded = encode_json_blob("{\"r\":255}"); - assert_eq!(&encoded[8..], b"{\"r\":255}"); - let len = unsafe { json_blob_len(encoded.as_ptr()) }; - assert_eq!(len, 9); - } - - /// #654: targeted get/set blobs round-trip; legacy 2-field blobs decode - /// as self-targeted so pre-#654 programs keep running. - #[test] - fn targeted_name_blobs_round_trip_and_accept_legacy() { - for target in [RefTarget::SelfActor, RefTarget::RefPin] { - let bytes = encode_targeted_name_blob("Light", "intensity", target); - assert_eq!( - decode_targeted_name_blob(&bytes), - Some(NameFields { - class_name: "Light".into(), - member: "intensity".into(), - target, - }) - ); - } - let legacy = encode_name_blob("Light", "intensity"); - assert_eq!( - decode_targeted_name_blob(&legacy).map(|f| f.target), - Some(RefTarget::SelfActor) - ); - // Unknown mode field is refused, never guessed. - let bad = encode_targeted_name_blob("Light", "intensity", RefTarget::SelfActor); - let mut tampered = bad.clone(); - *tampered.last_mut().unwrap() = b'x'; - assert!(decode_targeted_name_blob(&tampered).is_none()); - } - - /// #654: targeted call blobs round-trip with argc preserved; the - /// legacy 3-field form decodes as self-targeted. A member literally - /// named "self" cannot fool the count-based discrimination. - #[test] - fn targeted_call_blobs_round_trip_count_is_authoritative() { - for target in [RefTarget::SelfActor, RefTarget::RefPin] { - let bytes = encode_targeted_call_name_blob("Door", "open", 2, target); - assert_eq!( - decode_targeted_call_name_blob(&bytes), - Some(CallNameFields { - class_name: "Door".into(), - method: "open".into(), - arg_count: 2, - target, - }) - ); - } - let legacy = encode_call_name_blob("Door", "open", 1); - assert_eq!( - decode_targeted_call_name_blob(&legacy), - Some(CallNameFields { - class_name: "Door".into(), - method: "open".into(), - arg_count: 1, - target: RefTarget::SelfActor, - }) - ); - } - - /// #654: identity-op node types parse with empty class/member; the - /// class-carrying kinds still demand both fields. - #[test] - fn identity_op_node_types_parse() { - assert_eq!( - parse_node_type("get_component_ref::Light::2"), - Some((CompOpKind::GetRef, "Light", "2")) - ); - assert_eq!( - parse_node_type("find_object_by_stable_id"), - Some((CompOpKind::FindByStableId, "", "")) - ); - assert_eq!( - parse_node_type("find_object_by_name"), - Some((CompOpKind::FindByName, "", "")) - ); - assert_eq!( - parse_node_type("object_ref_literal"), - Some((CompOpKind::ObjectLiteral, "", "")) - ); - assert_eq!(parse_node_type("get_component_ref::NoIndex"), None); - assert!(!CompOpKind::FindByStableId.uses_name_blob()); - assert!(CompOpKind::GetRef.uses_name_blob()); - } -} diff --git a/src/bytecode/mod.rs b/src/bytecode/mod.rs deleted file mode 100644 index 6edbe4f..0000000 --- a/src/bytecode/mod.rs +++ /dev/null @@ -1,103 +0,0 @@ -use serde::{Deserialize, Serialize}; -// Re-export from pulsar_std so that callers only need one import. -pub use pulsar_std::TypeSlot; - -pub mod comp_ops; -pub use comp_ops::{parse_node_type, CompOpKind, ComponentOpRef}; - -pub type LabelId = usize; - -/// A single bytecode instruction. -/// -/// Values live in a flat byte arena. Every instruction references arena positions -/// by byte offset. The VM has zero knowledge of concrete Rust types: it only moves -/// pointers and calls pre-resolved function addresses. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Instruction { - /// Copy `bytes` into the arena at `offset`. Used to initialise constant inputs. - InitBytes { - offset: usize, - bytes: Vec, - }, - - /// Store a `TypeSlot { size, align }` at `offset` inside the arena. - /// - /// Emitted by the codegen whenever a generic node's type parameter `T` has - /// been resolved at graph-compile time. The VM writes the struct into the - /// arena; the dispatch function reads it via `type_slots[i]`. - InitTypeSlot { - offset: usize, - size: usize, - align: usize, - }, - - /// Direct function call — no dispatch table, no type lookup. - /// - /// `fn_ptr` is patched by the executor (via dlsym) before execution. - /// `input_offsets` are byte offsets into the arena, one per argument. - /// `output_offset` is the byte offset where the return value is written - /// (ignored when `has_output` is false). - /// `node_type` is retained only for the executor's dlsym resolution and - /// debug output; the VM hot loop never reads it. - /// `type_slot_offsets` are arena offsets of `TypeSlot` values, one per - /// resolved generic type parameter. Empty for fully-concrete functions. - Call { - fn_ptr: u64, - node_type: String, - input_offsets: Vec, - output_offset: usize, - has_output: bool, - type_slot_offsets: Vec, - }, - - /// Copy bytes from one arena slot into another. - LoadVar { - source_offset: usize, - output_offset: usize, - size: usize, - }, - - /// Store bytes from an input slot into a variable slot. - StoreVar { - input_offset: usize, - target_offset: usize, - size: usize, - }, - - /// Branch on the bool byte at `condition_offset` (non-zero → true). - JumpIf { - condition_offset: usize, - true_label: LabelId, - false_label: LabelId, - }, - Jump(LabelId), - Label(LabelId), - Return, -} - -/// A compiled blueprint program ready to be prepared and run. -/// -/// After compilation, all `fn_ptr` fields are zero. -/// Call `BpExecutor::prepare` to patch them from the native cdylib. -/// After preparation `pbgc::vm::run` takes only `&BpProgram`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BpProgram { - pub name: String, - /// Total byte size of the arena required to execute this program. - pub arena_size: usize, - /// Peak number of input-pointer arguments across all Call instructions. - /// Used to pre-allocate the arg_ptrs scratch buffer in the VM. - pub max_args_count: usize, - pub instructions: Vec, -} - -impl BpProgram { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - arena_size: 0, - max_args_count: 0, - instructions: Vec::new(), - } - } -} diff --git a/src/codegen/bytecode_codegen.rs b/src/codegen/bytecode_codegen.rs deleted file mode 100644 index 699ad3e..0000000 --- a/src/codegen/bytecode_codegen.rs +++ /dev/null @@ -1,1629 +0,0 @@ -use crate::bytecode::comp_ops::{ - encode_json_blob, encode_targeted_call_name_blob, encode_targeted_name_blob, parse_node_type, - CompOpKind, ComponentOpRef, RefTarget, JSON_BLOB_CAPACITY, -}; -use crate::bytecode::{BpProgram, Instruction, LabelId}; -use crate::metadata::BlueprintMetadataProvider; -use graphy::core::NodeMetadataProvider; -use graphy::{ - DataResolver, DataType, ExecutionRouting, GraphDescription, GraphyError, NodeInstance, - NodeTypes, -}; -use std::collections::{HashMap, HashSet}; - -// ── Layout allocator ───────────────────────────────────────────────────────── - -struct LayoutAllocator { - next_offset: usize, -} - -impl LayoutAllocator { - fn new() -> Self { - Self { next_offset: 0 } - } - - /// Align `next_offset` up to `align`, allocate `size` bytes, return the offset. - fn alloc(&mut self, size: usize, align: usize) -> usize { - let offset = (self.next_offset + align - 1) & !(align - 1); - self.next_offset = offset + size; - offset - } -} - -// ── Constant serialization ──────────────────────────────────────────────────── -// -// Converts a constant string (from the graph) into the exact little-endian bytes -// that represent the value in memory, matching what `ptr::read::` expects. -// -// Only primitive types that can appear as blueprint pin constants are handled. -// Any other type_string is an error — there is no silent fallback. - -fn serialize_const(raw: &str, ty: &str) -> Result, GraphyError> { - // Strip Rust numeric suffixes like `3i64`, `2.0f32`. - let s = raw - .trim() - .trim_end_matches(|c: char| c.is_ascii_alphabetic()) - .trim(); - - let bytes = match ty.trim() { - "f64" => s - .parse::() - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as f64", s)))? - .to_le_bytes() - .to_vec(), - "f32" => s - .parse::() - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as f32", s)))? - .to_le_bytes() - .to_vec(), - "i64" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as i64)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as i64", s)))? - .to_le_bytes() - .to_vec(), - "u64" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as u64)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as u64", s)))? - .to_le_bytes() - .to_vec(), - "i32" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as i32)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as i32", s)))? - .to_le_bytes() - .to_vec(), - "u32" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as u32)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as u32", s)))? - .to_le_bytes() - .to_vec(), - "i16" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as i16)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as i16", s)))? - .to_le_bytes() - .to_vec(), - "u16" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as u16)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as u16", s)))? - .to_le_bytes() - .to_vec(), - "i8" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as i8)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as i8", s)))? - .to_le_bytes() - .to_vec(), - "u8" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as u8)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as u8", s)))? - .to_le_bytes() - .to_vec(), - "bool" => { - let v = s == "true" || s.parse::().unwrap_or(0.0) != 0.0; - vec![v as u8] - } - "isize" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as isize)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as isize", s)))? - .to_le_bytes() - .to_vec(), - "usize" => s - .parse::() - .or_else(|_| s.parse::().map(|f| f as usize)) - .map_err(|_| GraphyError::Custom(format!("Cannot parse {:?} as usize", s)))? - .to_le_bytes() - .to_vec(), - other => { - return Err(GraphyError::Custom(format!( - "Constant serialization is not supported for type '{}'. \ - Only primitive numeric types and bool can be graph constants.", - other - ))) - } - }; - Ok(bytes) -} - -// ── BytecodeCodegen ─────────────────────────────────────────────────────────── - -pub struct BytecodeCodegen<'a> { - graph: &'a GraphDescription, - metadata_provider: &'a BlueprintMetadataProvider, - data_resolver: &'a DataResolver, - exec_routing: &'a ExecutionRouting, - variables: HashMap, - - instructions: Vec, - /// node_id → OutputSlot for that node's output value in the arena. - /// For single-output nodes, the inner map is empty and `base_offset` is used directly. - /// For multi-output nodes, `base_offset` points to the packed tuple and `field_offsets` - /// maps pin_name → offset within the tuple. - output_offsets: HashMap, - /// canonical key → byte offset of a previously emitted constant - const_offsets: HashMap, - /// `class\0member` → byte offset of the staged component-op name blob. - /// Reset per event like [`Self::const_offsets`]. - comp_name_blobs: HashMap, - /// `(class, member)` pairs referenced by component ops across all events, - /// deduplicated — surfaced to the runtime alongside the programs. - component_refs: Vec, - /// variable name → (arena offset, size, align) - variable_slots: HashMap, - - layout: LayoutAllocator, - next_label: LabelId, - visited: HashSet, - in_progress: HashSet, - max_args_count: usize, -} - -/// Describes the arena storage for a single node's output(s). -struct OutputSlot { - /// Byte offset of the base of the output value (single value or packed tuple). - base_offset: usize, - /// For multi-output nodes: map from output pin name → field offset within the tuple. - /// Empty for single-output nodes. - field_offsets: HashMap, -} - -impl<'a> BytecodeCodegen<'a> { - pub fn new( - graph: &'a GraphDescription, - metadata_provider: &'a BlueprintMetadataProvider, - data_resolver: &'a DataResolver, - exec_routing: &'a ExecutionRouting, - variables: HashMap, - ) -> Self { - Self { - graph, - metadata_provider, - data_resolver, - exec_routing, - variables, - instructions: Vec::new(), - output_offsets: HashMap::new(), - const_offsets: HashMap::new(), - comp_name_blobs: HashMap::new(), - component_refs: Vec::new(), - variable_slots: HashMap::new(), - layout: LayoutAllocator::new(), - next_label: 0, - visited: HashSet::new(), - in_progress: HashSet::new(), - max_args_count: 0, - } - } - - fn alloc_label(&mut self) -> LabelId { - let l = self.next_label; - self.next_label += 1; - l - } - - pub fn generate_programs(&mut self) -> Result, GraphyError> { - self.preallocate_variable_slots()?; - - let event_nodes: Vec = self - .graph - .nodes - .values() - .filter(|n| { - self.metadata_provider - .get_node_metadata(&n.node_type) - .map(|m| m.node_type == NodeTypes::event) - .unwrap_or({ - tracing::warn!( - "Node {} has no metadata, or it was not valid for compilation", - n.node_type - ); - false - }) - }) - .cloned() - .collect(); - - if event_nodes.is_empty() { - return Err(GraphyError::CodeGeneration( - "No event nodes found in graph".to_string(), - )); - } - - let mut programs = Vec::new(); - for event in event_nodes { - programs.push(self.compile_event(&event)?); - } - Ok(programs) - } - - /// Every `(kind, class, member)` component operation referenced by the - /// compiled programs, deduplicated in first-appearance order. - pub fn component_refs(&self) -> &[ComponentOpRef] { - &self.component_refs - } - - fn preallocate_variable_slots(&mut self) -> Result<(), GraphyError> { - let mut vars: Vec<(String, String)> = self - .variables - .iter() - .map(|(name, ty)| (name.clone(), ty.clone())) - .collect(); - vars.sort_by(|a, b| a.0.cmp(&b.0)); - - for (var_name, type_str) in vars { - self.ensure_variable_slot(&var_name, &type_str)?; - } - Ok(()) - } - - fn compile_event(&mut self, event: &NodeInstance) -> Result { - // Reset per-event state while preserving the shared layout allocator. - let prev_instructions = std::mem::take(&mut self.instructions); - let prev_visited = std::mem::take(&mut self.visited); - let prev_output_offsets = std::mem::take(&mut self.output_offsets); - let prev_const_offsets = std::mem::take(&mut self.const_offsets); - let prev_comp_blobs = std::mem::take(&mut self.comp_name_blobs); - let prev_max_args = self.max_args_count; - self.max_args_count = 0; - - for pin in &event.outputs { - if matches!(pin.pin.data_type, DataType::Exec) { - for nid in self - .exec_routing - .get_connected_nodes(&event.id, &pin.id) - .to_vec() - { - if let Some(n) = self.graph.nodes.get(&nid).cloned() { - self.emit_exec_node(&n)?; - } - } - } - } - self.instructions.push(Instruction::Return); - - // Custom event On nodes (node_type starts with "on_") — no static metadata. - let event_name = if event.node_type.starts_with("on_") { - event.node_type.clone() - } else { - let meta = self - .metadata_provider - .get_node_metadata(&event.node_type) - .ok_or_else(|| GraphyError::NodeNotFound(event.node_type.clone()))?; - meta.name.clone() - }; - - let instrs = std::mem::replace(&mut self.instructions, prev_instructions); - let arena_size = self.layout.next_offset; - let max_args_count = self.max_args_count; - - self.visited = prev_visited; - self.output_offsets = prev_output_offsets; - self.const_offsets = prev_const_offsets; - self.comp_name_blobs = prev_comp_blobs; - self.max_args_count = prev_max_args; - - let mut prog = BpProgram::new(event_name); - prog.instructions = instrs; - prog.arena_size = arena_size; - prog.max_args_count = max_args_count; - Ok(prog) - } - - // ── Exec chain ──────────────────────────────────────────────────────────── - - fn emit_exec_node(&mut self, node: &NodeInstance) -> Result<(), GraphyError> { - if self.visited.contains(&node.id) { - return Ok(()); - } - self.visited.insert(node.id.clone()); - - if node - .node_type - .strip_prefix("set_") - .is_some_and(|name| self.variables.contains_key(name)) - { - return self.emit_setter(node); - } - - // Custom event dispatch node — emit a call to `emit_event` native function - if node.node_type == "emit_custom_event" { - return self.emit_custom_event_dispatch(node); - } - - // Component ops route through their own staged-operand ABI (see - // `bytecode::comp_ops`) instead of pulsar_std metadata + dlsym. - if let Some(op) = parse_node_type(&node.node_type) { - return self.emit_comp_node(node, op); - } - - let meta = self - .metadata_provider - .get_node_metadata(&node.node_type) - .ok_or_else(|| GraphyError::NodeNotFound(node.node_type.clone()))? - .clone(); - - match meta.node_type { - NodeTypes::pure | NodeTypes::event => Ok(()), - NodeTypes::fn_ => self.emit_fn_node(node, &meta), - NodeTypes::control_flow => self.emit_control_flow(node, &meta), - } - } - - fn emit_custom_event_dispatch(&mut self, node: &NodeInstance) -> Result<(), GraphyError> { - let event_uid = node.properties.get("event_uid").cloned().ok_or_else(|| { - GraphyError::Custom(format!( - "emit_custom_event node '{}' has no event_uid property", - node.id - )) - })?; - - // Collect input offsets for all data pins (skip execution inputs) - let data_pins: Vec<_> = node - .inputs - .iter() - .filter(|p| !matches!(p.pin.data_type, DataType::Exec)) - .collect(); - - let mut input_offsets = Vec::new(); - for pin in &data_pins { - let offset = - self.resolve_input_offset(&node.id, &node.node_type, &pin.id, &pin.pin.name)?; - input_offsets.push(offset); - } - - self.max_args_count = self.max_args_count.max(input_offsets.len()); - let output_offset = 0; // emit_event has no return value - - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: "emit_event".to_string(), - input_offsets, - output_offset, - has_output: false, - type_slot_offsets: Vec::new(), - }); - self.follow_exec_outputs(node) - } - - // ── Component ops ───────────────────────────────────────────────────────── - // - // Compiled per the ABI in `bytecode::comp_ops`: a plain `Call` instruction - // carrying the full `comp_*::Class::Member` string, with the class/member - // name blob staged as `input_offsets[0]` and every value travelling as a - // length-prefixed JSON blob. The executor routes on the node_type prefix. - // - // #654: when a comp op's `component_ref` input pin is CONNECTED, the op - // addresses that reference's actor instead of the executing instance — - // the name blob gains the trailing `pin` target field and one extra - // reference operand is staged between the name blob and the values. - // Identity producers (`get_component_ref`, `find_object_by_*`, - // `object_ref_literal`) are pure ops in the same routing table. - - fn emit_comp_node( - &mut self, - node: &NodeInstance, - (kind, class_name, member): (CompOpKind, &str, &str), - ) -> Result<(), GraphyError> { - match kind { - CompOpKind::GetProp => { - self.emit_comp_get_prop(node, class_name, member)?; - self.follow_exec_outputs(node) - } - CompOpKind::SetProp => self.emit_comp_set_prop(node, class_name, member), - CompOpKind::Call => self.emit_comp_call(node, class_name, member), - CompOpKind::GetRef => { - self.emit_comp_get_ref(node, class_name, member)?; - self.follow_exec_outputs(node) - } - CompOpKind::FindByStableId | CompOpKind::FindByName | CompOpKind::ObjectLiteral => { - self.emit_identity_resolver(node)?; - self.follow_exec_outputs(node) - } - } - } - - /// Whether this node's `component_ref` input pin is wired to an - /// identity producer (or carries a constant reference JSON). - fn component_ref_pin_connected(&self, node: &NodeInstance) -> bool { - use graphy::analysis::DataSource; - matches!( - self.data_resolver - .get_input_source(&node.id, "component_ref"), - Some(DataSource::Connection { .. }) | Some(DataSource::Constant(_)) - ) - } - - /// Resolve the staged offset of a node's `component_ref` operand. - fn resolve_component_ref_operand(&mut self, node: &NodeInstance) -> Result { - let pin = node - .inputs - .iter() - .find(|p| p.id == "component_ref" || p.pin.name == "component") - .ok_or_else(|| { - GraphyError::Custom(format!( - "pin-targeted component op '{}' has no component_ref pin", - node.id - )) - })?; - self.resolve_comp_value_blob(node, pin) - } - - /// Stage the get/set name blob for `target` (#654 ABI): the trailing - /// target field is ALWAYS present so the runtime reader can scan a fixed - /// field count with no length side-channel. - fn stage_targeted_name_blob( - &mut self, - class_name: &str, - member: &str, - target: RefTarget, - ) -> Result { - let key = format!("{}\0{}\0{}", class_name, member, target.as_field()); - if let Some(&offset) = self.comp_name_blobs.get(&key) { - return Ok(offset); - } - let bytes = encode_targeted_name_blob(class_name, member, target); - let offset = self.layout.alloc(bytes.len(), 1); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - self.comp_name_blobs.insert(key, offset); - Ok(offset) - } - - fn emit_comp_set_prop( - &mut self, - node: &NodeInstance, - class_name: &str, - member: &str, - ) -> Result<(), GraphyError> { - let targeted = self.component_ref_pin_connected(node); - let target = if targeted { - RefTarget::RefPin - } else { - RefTarget::SelfActor - }; - let name_offset = self.stage_targeted_name_blob(class_name, member, target)?; - let mut input_offsets = vec![name_offset]; - if targeted { - input_offsets.push(self.resolve_component_ref_operand(node)?); - } - - let data_pins: Vec<_> = node - .inputs - .iter() - .filter(|p| !matches!(p.pin.data_type, DataType::Exec)) - .filter(|p| p.id != "component_ref" && p.pin.name != "component") - .collect(); - if data_pins.len() != 1 { - return Err(GraphyError::Custom(format!( - "comp_set_prop node '{}' must have exactly one value input", - node.id - ))); - } - let value_offset = self.resolve_comp_value_blob(node, data_pins[0])?; - input_offsets.push(value_offset); - self.max_args_count = self.max_args_count.max(input_offsets.len()); - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: node.node_type.clone(), - input_offsets, - output_offset: 0, - has_output: false, - type_slot_offsets: Vec::new(), - }); - self.record_component_ref(CompOpKind::SetProp, class_name, member); - self.follow_exec_outputs(node) - } - - fn emit_comp_call( - &mut self, - node: &NodeInstance, - class_name: &str, - member: &str, - ) -> Result<(), GraphyError> { - // The name blob stages the argument count so the runtime handler - // knows how many value operands follow. - let arg_count = node - .inputs - .iter() - .filter(|p| !matches!(p.pin.data_type, DataType::Exec)) - .filter(|p| p.id != "component_ref" && p.pin.name != "component") - .count(); - let targeted = self.component_ref_pin_connected(node); - let target = if targeted { - RefTarget::RefPin - } else { - RefTarget::SelfActor - }; - let name_key = format!("{}\0{}\0call\0{}", class_name, member, target.as_field()); - let name_offset = match self.comp_name_blobs.get(&name_key) { - Some(&offset) => offset, - None => { - let bytes = encode_targeted_call_name_blob(class_name, member, arg_count, target); - let offset = self.layout.alloc(bytes.len(), 1); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - self.comp_name_blobs.insert(name_key, offset); - offset - } - }; - let mut input_offsets = vec![name_offset]; - if targeted { - input_offsets.push(self.resolve_component_ref_operand(node)?); - } - for pin in node - .inputs - .iter() - .filter(|p| !matches!(p.pin.data_type, DataType::Exec)) - .filter(|p| p.id != "component_ref" && p.pin.name != "component") - { - input_offsets.push(self.resolve_comp_value_blob(node, pin)?); - } - self.max_args_count = self.max_args_count.max(input_offsets.len()); - let has_output = node - .outputs - .iter() - .any(|p| !matches!(p.pin.data_type, DataType::Exec)); - let (output_offset, has_output) = if has_output { - (self.alloc_json_blob_slot(), true) - } else { - (0, false) - }; - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: node.node_type.clone(), - input_offsets, - output_offset, - has_output, - type_slot_offsets: Vec::new(), - }); - if has_output { - self.output_offsets.insert( - node.id.clone(), - OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }, - ); - } - self.record_component_ref(CompOpKind::Call, class_name, member); - self.follow_exec_outputs(node) - } - - /// Emit a pure `comp_get_prop` as an arena producer; returns the offset of - /// its reserved output blob. - fn emit_comp_get_prop( - &mut self, - node: &NodeInstance, - class_name: &str, - member: &str, - ) -> Result { - if let Some(slot) = self.output_offsets.get(&node.id) { - return Ok(slot.base_offset); - } - let targeted = self.component_ref_pin_connected(node); - let target = if targeted { - RefTarget::RefPin - } else { - RefTarget::SelfActor - }; - let name_offset = self.stage_targeted_name_blob(class_name, member, target)?; - let mut input_offsets = vec![name_offset]; - if targeted { - input_offsets.push(self.resolve_component_ref_operand(node)?); - } - let output_offset = self.alloc_json_blob_slot(); - self.max_args_count = self.max_args_count.max(input_offsets.len()); - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: node.node_type.clone(), - input_offsets, - output_offset, - has_output: true, - type_slot_offsets: Vec::new(), - }); - self.output_offsets.insert( - node.id.clone(), - OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }, - ); - self.record_component_ref(CompOpKind::GetProp, class_name, member); - Ok(output_offset) - } - - /// Emit a pure `get_component_ref::{Class}::{Index}` producer (#654): - /// outputs the referenced component as its #642 JSON shape, resolving - /// entity bits at runtime. An optional `actor` input pin redirects the - /// lookup to another object (fed by `find_object_by_*` or another ref - /// producer); unconnected means the executing instance itself. - fn emit_comp_get_ref( - &mut self, - node: &NodeInstance, - class_name: &str, - member: &str, - ) -> Result { - if let Some(slot) = self.output_offsets.get(&node.id) { - return Ok(slot.base_offset); - } - let actor_pin = node.inputs.iter().find(|p| p.id == "actor"); - let target = if actor_pin.is_some() { - RefTarget::RefPin - } else { - RefTarget::SelfActor - }; - let name_key = format!("ref\0{class_name}\0{member}\0{}", target.as_field()); - let name_offset = match self.comp_name_blobs.get(&name_key) { - Some(&offset) => offset, - None => { - let bytes = encode_targeted_name_blob(class_name, member, target); - let offset = self.layout.alloc(bytes.len(), 1); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - self.comp_name_blobs.insert(name_key, offset); - offset - } - }; - let mut input_offsets = vec![name_offset]; - if let Some(pin) = actor_pin { - input_offsets.push(self.resolve_comp_value_blob(node, pin)?); - } - let output_offset = self.alloc_json_blob_slot(); - self.max_args_count = self.max_args_count.max(input_offsets.len()); - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: node.node_type.clone(), - input_offsets, - output_offset, - has_output: true, - type_slot_offsets: Vec::new(), - }); - self.output_offsets.insert( - node.id.clone(), - OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }, - ); - self.record_component_ref(CompOpKind::GetRef, class_name, member); - Ok(output_offset) - } - - /// Emit an identity resolver (`find_object_by_stable_id` / - /// `find_object_by_name` / `object_ref_literal`) — one staged JSON - /// operand in, a resolved reference blob out (#654). All resolution is - /// deferred to runtime against the live world, so references survive - /// save/load cycles. - fn emit_identity_resolver(&mut self, node: &NodeInstance) -> Result { - if let Some(slot) = self.output_offsets.get(&node.id) { - return Ok(slot.base_offset); - } - let needle = node - .inputs - .iter() - .find(|p| !matches!(p.pin.data_type, DataType::Exec)) - .ok_or_else(|| { - GraphyError::Custom(format!( - "identity resolver node '{}' has no operand input", - node.id - )) - })?; - let operand_offset = self.resolve_identity_operand(node, needle)?; - let output_offset = self.alloc_json_blob_slot(); - self.max_args_count = self.max_args_count.max(1); - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: node.node_type.clone(), - input_offsets: vec![operand_offset], - output_offset, - has_output: true, - type_slot_offsets: Vec::new(), - }); - self.output_offsets.insert( - node.id.clone(), - OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }, - ); - Ok(output_offset) - } - - /// Resolve an identity resolver's operand. For `object_ref_literal` the - /// serialized reference comes from the node's properties (authored by - /// the picker/drag flow); resolvers take their needle like any value - /// input (constant or connection). - fn resolve_identity_operand( - &mut self, - node: &NodeInstance, - pin: &graphy::PinInstance, - ) -> Result { - use graphy::analysis::DataSource; - - if parse_node_type(&node.node_type).map(|(k, _, _)| k) == Some(CompOpKind::ObjectLiteral) { - let stable_id = node - .properties - .get("stable_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - GraphyError::Custom(format!( - "object_ref_literal node '{}' has no stable_id property", - node.id - )) - })?; - let class_name = node - .properties - .get("class_name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let component_index = node - .properties - .get("component_index") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let literal = serde_json::json!({ - "stable_id": stable_id, - "class_name": class_name, - "component_index": component_index, - }); - let bytes = encode_json_blob(&literal.to_string()); - let offset = self.layout.alloc(bytes.len(), 8); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - return Ok(offset); - } - - match self.data_resolver.get_input_source(&node.id, &pin.id) { - Some(DataSource::Constant(value)) => { - let bytes = encode_json_blob(&value.to_string()); - let offset = self.layout.alloc(bytes.len(), 8); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - Ok(offset) - } - Some(DataSource::Connection { - source_node_id, - source_pin, - }) => { - self.ensure_connected_output(source_node_id)?; - self.output_offset_for(source_node_id, source_pin) - .ok_or_else(|| { - GraphyError::Custom(format!( - "No arena offset for output '{}.{}'", - source_node_id, source_pin - )) - }) - } - Some(DataSource::Default) => { - let bytes = encode_json_blob("\"\""); - let offset = self.layout.alloc(bytes.len(), 8); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - Ok(offset) - } - None => Err(GraphyError::Custom(format!( - "No data source for {}.{}", - node.id, pin.id - ))), - } - } - - /// Resolve one component-op value input to an arena offset holding a - /// length-prefixed JSON blob. - /// - /// Constants stage their exact encoded bytes. Connections are accepted - /// from the graph's blob producers — component getters AND (#654) the - /// identity resolvers (`get_component_ref`, `find_object_by_*`, - /// `object_ref_literal`), which all emit runtime-written JSON blobs; - /// native sources are refused until a live conversion exists for them. - fn resolve_comp_value_blob( - &mut self, - node: &NodeInstance, - pin: &graphy::PinInstance, - ) -> Result { - use graphy::analysis::DataSource; - - match self.data_resolver.get_input_source(&node.id, &pin.id) { - Some(DataSource::Constant(value)) => { - let bytes = encode_json_blob(&value.to_string()); - let offset = self.layout.alloc(bytes.len(), 8); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - Ok(offset) - } - Some(DataSource::Connection { - source_node_id, - source_pin, - }) => { - let accepts = self - .graph - .nodes - .get(source_node_id) - .and_then(|n| parse_node_type(&n.node_type)) - .map(|(kind, _, _)| kind == CompOpKind::GetProp || is_identity_producer(kind)) - .unwrap_or(false); - if !accepts { - return Err(GraphyError::Custom(format!( - "VM target: component value inputs must be constants, component \ - getters, or identity references (node '{}')", - node.id - ))); - } - self.ensure_connected_output(source_node_id)?; - self.output_offset_for(source_node_id, source_pin) - .ok_or_else(|| { - GraphyError::Custom(format!( - "No arena offset for output '{}.{}'", - source_node_id, source_pin - )) - }) - } - Some(DataSource::Default) => { - let bytes = encode_json_blob("null"); - let offset = self.layout.alloc(bytes.len(), 8); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - Ok(offset) - } - None => Err(GraphyError::Custom(format!( - "No data source for {}.{}", - node.id, pin.id - ))), - } - } - - /// Reserve capacity for a runtime-written JSON blob. - fn alloc_json_blob_slot(&mut self) -> usize { - self.layout.alloc(8 + JSON_BLOB_CAPACITY, 8) - } - - fn record_component_ref(&mut self, kind: CompOpKind, class_name: &str, member: &str) { - let reference = ComponentOpRef { - kind, - class_name: class_name.to_string(), - member: member.to_string(), - }; - if !self.component_refs.contains(&reference) { - self.component_refs.push(reference); - } - } - - fn emit_fn_node( - &mut self, - node: &NodeInstance, - meta: &graphy::core::NodeMetadata, - ) -> Result<(), GraphyError> { - let input_offsets = self.collect_input_offsets_for(node, meta)?; - self.max_args_count = self.max_args_count.max(input_offsets.len()); - let (output_offset, has_output) = - self.alloc_output_for_meta(&node.id, &node.node_type, meta); - let type_slot_offsets = self.collect_type_slot_offsets_for(node)?; - - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: meta.name.to_string(), - input_offsets, - output_offset, - has_output, - type_slot_offsets, - }); - self.follow_exec_outputs(node) - } - - fn emit_setter(&mut self, node: &NodeInstance) -> Result<(), GraphyError> { - let var_name = node - .node_type - .strip_prefix("set_") - .ok_or_else(|| GraphyError::Custom(format!("Bad setter: {}", node.node_type)))?; - let var_type = self.variable_type(var_name)?; - let (var_offset, var_size, _) = self.ensure_variable_slot(var_name, &var_type)?; - - let val_pin = node - .inputs - .iter() - .find(|p| p.pin.name == "value") - .ok_or_else(|| GraphyError::Custom(format!("No value pin on setter '{}'", node.id)))?; - let val_offset = - self.resolve_input_offset(&node.id, &node.node_type, &val_pin.id, &val_pin.pin.name)?; - self.max_args_count = self.max_args_count.max(1); - - self.instructions.push(Instruction::StoreVar { - input_offset: val_offset, - target_offset: var_offset, - size: var_size, - }); - self.follow_exec_outputs(node) - } - - fn emit_getter(&mut self, node: &NodeInstance) -> Result { - let var_name = node - .node_type - .strip_prefix("get_") - .ok_or_else(|| GraphyError::Custom(format!("Bad getter: {}", node.node_type)))?; - let var_type = self.variable_type(var_name)?; - let (source_offset, var_size, align) = self.ensure_variable_slot(var_name, &var_type)?; - let output_offset = self.layout.alloc(var_size, align); - - self.instructions.push(Instruction::LoadVar { - source_offset, - output_offset, - size: var_size, - }); - self.output_offsets.insert( - node.id.clone(), - OutputSlot { - base_offset: output_offset, - field_offsets: HashMap::new(), - }, - ); - Ok(output_offset) - } - - fn emit_control_flow( - &mut self, - node: &NodeInstance, - meta: &graphy::core::NodeMetadata, - ) -> Result<(), GraphyError> { - let exec_pins: Vec<_> = node - .outputs - .iter() - .filter(|p| matches!(p.pin.data_type, DataType::Exec)) - .collect(); - let data_ins: Vec<_> = node - .inputs - .iter() - .filter(|p| !matches!(p.pin.data_type, DataType::Exec)) - .collect(); - - if exec_pins.len() == 2 && data_ins.len() == 1 { - return self.emit_two_way_branch(node, &data_ins, &exec_pins); - } - if data_ins.is_empty() && exec_pins.len() > 1 { - return self.emit_sequential(node, &exec_pins); - } - self.emit_fn_node(node, meta) - } - - fn emit_two_way_branch( - &mut self, - node: &NodeInstance, - data_ins: &[&graphy::PinInstance], - exec_pins: &[&graphy::PinInstance], - ) -> Result<(), GraphyError> { - let cond_offset = self.resolve_input_offset( - &node.id, - &node.node_type, - &data_ins[0].id, - &data_ins[0].pin.name, - )?; - let true_lbl = self.alloc_label(); - let false_lbl = self.alloc_label(); - let end_lbl = self.alloc_label(); - - self.instructions.push(Instruction::JumpIf { - condition_offset: cond_offset, - true_label: true_lbl, - false_label: false_lbl, - }); - - self.instructions.push(Instruction::Label(true_lbl)); - let saved = self.visited.clone(); - for nid in self - .exec_routing - .get_connected_nodes(&node.id, &exec_pins[0].id) - .to_vec() - { - if let Some(n) = self.graph.nodes.get(&nid).cloned() { - self.emit_exec_node(&n)?; - } - } - self.instructions.push(Instruction::Jump(end_lbl)); - - self.instructions.push(Instruction::Label(false_lbl)); - self.visited = saved; - for nid in self - .exec_routing - .get_connected_nodes(&node.id, &exec_pins[1].id) - .to_vec() - { - if let Some(n) = self.graph.nodes.get(&nid).cloned() { - self.emit_exec_node(&n)?; - } - } - - self.instructions.push(Instruction::Label(end_lbl)); - Ok(()) - } - - fn emit_sequential( - &mut self, - node: &NodeInstance, - exec_pins: &[&graphy::PinInstance], - ) -> Result<(), GraphyError> { - for pin in exec_pins { - for nid in self - .exec_routing - .get_connected_nodes(&node.id, &pin.id) - .to_vec() - { - if let Some(n) = self.graph.nodes.get(&nid).cloned() { - self.emit_exec_node(&n)?; - } - } - } - Ok(()) - } - - fn follow_exec_outputs(&mut self, node: &NodeInstance) -> Result<(), GraphyError> { - for pin in &node.outputs { - if matches!(pin.pin.data_type, DataType::Exec) { - for nid in self - .exec_routing - .get_connected_nodes(&node.id, &pin.id) - .to_vec() - { - if let Some(n) = self.graph.nodes.get(&nid).cloned() { - self.emit_exec_node(&n)?; - } - } - } - } - Ok(()) - } - - // ── TypeSlot allocation ─────────────────────────────────────────────────── - // - // For a generic node call, bare-T parameters (those whose compile-time size - // is 0 from the T→() substitution) need a runtime TypeSlot in the arena so - // the dispatch shim can read element size/align from type_slots[i]. - // - // Algorithm: - // For each param with metadata size == 0: - // • Follow its data connection to the source node. - // • Read that node's return_size / return_align from the pulsar_std registry. - // • Deduplicate: if a TypeSlot for that (size, align) was already emitted - // for this call, reuse its offset. - // • Otherwise allocate space in the arena and emit InitTypeSlot. - // Return the slot offsets in the order they are needed by the shim - // (first-unique-bare-T-param first). - - fn collect_type_slot_offsets_for( - &mut self, - node: &NodeInstance, - ) -> Result, GraphyError> { - use graphy::analysis::DataSource; - - let raw_meta = match pulsar_std::get_all_nodes() - .iter() - .find(|m| m.name == node.node_type) - { - Some(m) => m, - None => return Ok(vec![]), // not in registry, no type slots - }; - - // Size/align of a TypeSlot value in the arena. - let slot_sz = std::mem::size_of::(); - let slot_al = std::mem::align_of::(); - - // Deduplicate within this call: (size, align) → arena offset - let mut seen: std::collections::HashMap<(usize, usize), usize> = - std::collections::HashMap::new(); - let mut offsets: Vec = Vec::new(); - - for raw_param in raw_meta.params { - // Only bare-T params signal size == 0 via the T→() macro substitution. - if raw_param.size != 0 { - continue; - } - - // Find the matching pin on the node instance. - let pin = match node.inputs.iter().find(|p| p.pin.name == raw_param.name) { - Some(p) => p, - None => continue, - }; - - // Follow the connection to the source node. - let (t_size, t_align) = match self.data_resolver.get_input_source(&node.id, &pin.id) { - Some(DataSource::Connection { source_node_id, .. }) => { - let src_type = match self.graph.nodes.get(source_node_id) { - Some(n) => n.node_type.clone(), - None => continue, - }; - self.metadata_provider - .return_layout(&src_type) - .unwrap_or((8, 8)) // safe fallback: 8-byte primitive - } - // Default / constant bare-T params: use 8-byte fallback. - // A zero-size default would produce an empty TypeSlot, so 8/8 is safer. - _ => (8, 8), - }; - - // Deduplicate: reuse an existing slot for the same (size, align). - let slot_offset = match seen.entry((t_size, t_align)) { - std::collections::hash_map::Entry::Occupied(e) => *e.get(), - std::collections::hash_map::Entry::Vacant(e) => { - let off = self.layout.alloc(slot_sz, slot_al); - self.instructions.push(Instruction::InitTypeSlot { - offset: off, - size: t_size, - align: t_align, - }); - *e.insert(off) - } - }; - offsets.push(slot_offset); - } - - Ok(offsets) - } - - // ── Output allocation ───────────────────────────────────────────────────── - // - // Layout comes from the compile-time return_size/return_align values baked - // into pulsar_std::NodeMetadata by the #[blueprint] macro. No type-string matching. - // - // For multi-output nodes (output_params non-empty), ONE slot is allocated for - // the entire packed tuple. Field offsets within the tuple are computed using - // the per-field size/align from OutputParamMeta, matching Rust's repr(Rust) - // layout. Consumers reference individual fields by adding the field offset - // to the base offset. - - fn alloc_output_for_meta( - &mut self, - node_id: &str, - node_type: &str, - meta: &graphy::core::NodeMetadata, - ) -> (usize, bool) { - let is_void = meta - .return_type - .as_ref() - .map(|rt| rt.type_string == "()") - .unwrap_or(true); - if is_void { - return (0, false); - } - let (size, align) = self - .metadata_provider - .return_layout(node_type) - .unwrap_or((8, 8)); // only fallback: node not in registry (e.g. intrinsics) - if size == 0 { - return (0, false); // macro flagged it as void - } - - // Check for multi-output via raw pulsar_std metadata (has per-field size/align) - let raw_meta = pulsar_std::get_all_nodes() - .iter() - .find(|m| m.name == node_type); - - if let Some(raw) = raw_meta { - if !raw.output_params.is_empty() { - // Multi-output: allocate one slot for the packed tuple - let offset = self.layout.alloc(size, align); - // Compute per-field offsets using Rust tuple layout (sequential, aligned) - let mut field_offsets = HashMap::new(); - let mut field_cursor = 0usize; - for (i, param) in raw.output_params.iter().enumerate() { - // Align cursor to this field's alignment - let aligned = (field_cursor + param.align - 1) & !(param.align - 1); - field_cursor = aligned; - field_offsets.insert(param.name.to_string(), aligned); - field_cursor += param.size; - // Also store by positional name .0, .1, etc. for reverse lookup - field_offsets.insert(format!(".{}", i), aligned); - } - let slot = OutputSlot { - base_offset: offset, - field_offsets, - }; - self.output_offsets.insert(node_id.to_string(), slot); - return (offset, true); - } - } - - // Single-output: allocate one slot for the value - let offset = self.layout.alloc(size, align); - let slot = OutputSlot { - base_offset: offset, - field_offsets: HashMap::new(), - }; - self.output_offsets.insert(node_id.to_string(), slot); - (offset, true) - } - - // ── Input resolution ────────────────────────────────────────────────────── - - fn collect_input_offsets_for( - &mut self, - node: &NodeInstance, - meta: &graphy::core::NodeMetadata, - ) -> Result, GraphyError> { - let mut offsets = Vec::new(); - for param in &meta.params { - let pin = node - .inputs - .iter() - .find(|p| p.pin.name == param.name) - .ok_or_else(|| { - GraphyError::Custom(format!( - "Input pin '{}' not found on node '{}'", - param.name, node.id - )) - })?; - offsets.push(self.resolve_input_offset( - &node.id, - &node.node_type, - &pin.id, - &pin.pin.name, - )?); - } - Ok(offsets) - } - - /// Returns the arena offset of one of a node's outputs after that node has - /// been emitted. Multi-output nodes store their fields in a packed slot; - /// single-output nodes use the slot base directly. - fn output_offset_for(&self, source_node_id: &str, source_pin_id: &str) -> Option { - let slot = self.output_offsets.get(source_node_id)?; - if slot.field_offsets.is_empty() { - return Some(slot.base_offset); - } - - let source_node = self.graph.nodes.get(source_node_id)?; - let pin_name = source_node - .outputs - .iter() - .find(|pin| pin.id == source_pin_id) - .map(|pin| pin.pin.name.as_str())?; - let field_offset = slot - .field_offsets - .get(pin_name) - .or_else(|| slot.field_offsets.get(&format!(".{}", pin_name)))?; - Some(slot.base_offset + field_offset) - } - - /// Emits the pure-node dependency closure for `source_node_id` without - /// recursing through a graph-shaped call stack. Deep Blueprint graphs are - /// common enough that recursive input resolution can otherwise overflow a - /// normal test or runtime thread stack. - fn ensure_connected_output(&mut self, source_node_id: &str) -> Result<(), GraphyError> { - use graphy::analysis::DataSource; - - enum ResolutionTask { - Visit(String), - Emit(String), - } - - if self.output_offsets.contains_key(source_node_id) { - return Ok(()); - } - - let mut tasks = vec![ResolutionTask::Visit(source_node_id.to_string())]; - let mut scheduled = HashSet::from([source_node_id.to_string()]); - - while let Some(task) = tasks.pop() { - match task { - ResolutionTask::Visit(node_id) => { - if self.output_offsets.contains_key(&node_id) { - scheduled.remove(&node_id); - continue; - } - - if !self.in_progress.insert(node_id.clone()) { - return Err(GraphyError::CyclicDependency); - } - - let node = self.graph.nodes.get(&node_id).ok_or_else(|| { - GraphyError::Custom(format!("Source node '{}' not found", node_id)) - })?; - - if node - .node_type - .strip_prefix("get_") - .is_some_and(|name| self.variables.contains_key(name)) - { - let node = node.clone(); - self.emit_getter(&node)?; - self.in_progress.remove(&node_id); - scheduled.remove(&node_id); - continue; - } - - // Pure component getters produce arena blobs like any - // other pure producer (ABI: `bytecode::comp_ops`). - if let Some((CompOpKind::GetProp, class_name, member)) = - parse_node_type(&node.node_type) - { - let node = node.clone(); - self.emit_comp_get_prop(&node, class_name, member)?; - self.in_progress.remove(&node_id); - scheduled.remove(&node_id); - continue; - } - - // Identity producers (#654) likewise produce arena - // reference blobs from pure node kinds. - if let Some((kind, class_name, member)) = parse_node_type(&node.node_type) { - if is_identity_producer(kind) { - let node = node.clone(); - match kind { - CompOpKind::GetRef => { - self.emit_comp_get_ref(&node, class_name, member)?; - } - _ => { - self.emit_identity_resolver(&node)?; - } - } - self.in_progress.remove(&node_id); - scheduled.remove(&node_id); - continue; - } - } - - let meta = self - .metadata_provider - .get_node_metadata(&node.node_type) - .ok_or_else(|| { - GraphyError::Custom(format!( - "No metadata for node '{}'", - node.node_type - )) - })?; - - if meta.node_type != NodeTypes::pure { - self.in_progress.remove(&node_id); - return Err(GraphyError::Custom(format!( - "No arena offset for node '{}'", - node_id - ))); - } - - tasks.push(ResolutionTask::Emit(node_id.clone())); - for param in &meta.params { - let pin = node - .inputs - .iter() - .find(|pin| pin.pin.name == param.name) - .ok_or_else(|| { - GraphyError::Custom(format!( - "Input pin '{}' not found on node '{}'", - param.name, node.id - )) - })?; - - if let Some(DataSource::Connection { source_node_id, .. }) = - self.data_resolver.get_input_source(&node.id, &pin.id) - { - if !self.output_offsets.contains_key(source_node_id) - && scheduled.insert(source_node_id.to_string()) - { - tasks.push(ResolutionTask::Visit(source_node_id.to_string())); - } - } - } - } - ResolutionTask::Emit(node_id) => { - if self.output_offsets.contains_key(&node_id) { - scheduled.remove(&node_id); - continue; - } - - let node = self.graph.nodes.get(&node_id).cloned().ok_or_else(|| { - GraphyError::Custom(format!("Source node '{}' not found", node_id)) - })?; - let meta = self - .metadata_provider - .get_node_metadata(&node.node_type) - .ok_or_else(|| { - GraphyError::Custom(format!( - "No metadata for node '{}'", - node.node_type - )) - })? - .clone(); - - let input_offsets = self.collect_input_offsets_for(&node, &meta)?; - self.max_args_count = self.max_args_count.max(input_offsets.len()); - let (output_offset, has_output) = - self.alloc_output_for_meta(&node.id, &node.node_type, &meta); - - if has_output { - let type_slot_offsets = self.collect_type_slot_offsets_for(&node)?; - self.instructions.push(Instruction::Call { - fn_ptr: 0, - node_type: meta.name.to_string(), - input_offsets, - output_offset, - has_output, - type_slot_offsets, - }); - } - - self.in_progress.remove(&node_id); - scheduled.remove(&node_id); - } - } - } - - Ok(()) - } - - fn resolve_input_offset( - &mut self, - node_id: &str, - node_type: &str, - pin_id: &str, - pin_name: &str, - ) -> Result { - use graphy::analysis::DataSource; - - match self.data_resolver.get_input_source(node_id, pin_id) { - Some(DataSource::Connection { - source_node_id, - source_pin, - }) => { - self.ensure_connected_output(source_node_id)?; - self.output_offset_for(source_node_id, source_pin) - .ok_or_else(|| { - GraphyError::Custom(format!( - "No arena offset for output '{}.{}'", - source_node_id, source_pin - )) - }) - } - - Some(DataSource::Constant(val)) => { - let key = format!("{}:{}:{}", node_id, pin_id, val); - if let Some(&off) = self.const_offsets.get(&key) { - return Ok(off); - } - let (size, align, ty_str) = self.param_layout_and_type(node_type, pin_name)?; - let off = self.emit_const(&val, &ty_str, size, align)?; - self.const_offsets.insert(key, off); - Ok(off) - } - - Some(DataSource::Default) => { - // Zero-initialised slot — arena is already zeroed at VM startup, - // so we only need to reserve the space. - let (size, align, _) = self.param_layout_and_type(node_type, pin_name)?; - Ok(self.layout.alloc(size, align)) - } - - None => Err(GraphyError::Custom(format!( - "No data source for {}.{}", - node_id, pin_id - ))), - } - } - - fn variable_type(&self, var_name: &str) -> Result { - self.variables - .get(var_name) - .cloned() - .ok_or_else(|| GraphyError::Custom(format!("Variable '{}' not found", var_name))) - } - - fn ensure_variable_slot( - &mut self, - var_name: &str, - type_str: &str, - ) -> Result<(usize, usize, usize), GraphyError> { - if let Some(slot) = self.variable_slots.get(var_name) { - return Ok(*slot); - } - - let (size, align) = variable_layout_for_type(type_str).ok_or_else(|| { - GraphyError::Custom(format!( - "Unsupported variable type '{}' for variable '{}'", - type_str, var_name - )) - })?; - - let offset = self.layout.alloc(size, align); - let slot = (offset, size, align); - self.variable_slots.insert(var_name.to_string(), slot); - Ok(slot) - } - - /// Returns (size, align, type_string) for a named input parameter, sourced - /// exclusively from the compile-time `pulsar_std` registry. - fn param_layout_and_type( - &self, - node_type: &str, - param_name: &str, - ) -> Result<(usize, usize, String), GraphyError> { - let (size, align) = self - .metadata_provider - .param_layout(node_type, param_name) - .ok_or_else(|| { - GraphyError::Custom(format!( - "No layout metadata for param '{}' on node '{}'.\ - Ensure the node is registered with #[blueprint].", - param_name, node_type - )) - })?; - - // The type string is only needed for constant serialization. - // Retrieve it from the raw pulsar_std NodeParameter. - let ty_str = pulsar_std::get_all_nodes() - .iter() - .find(|m| m.name == node_type) - .and_then(|m| m.params.iter().find(|p| p.name == param_name)) - .map(|p| p.ty.to_string()) - .ok_or_else(|| { - GraphyError::Custom(format!( - "No type info for param '{}' on node '{}'", - param_name, node_type - )) - })?; - - Ok((size, align, ty_str)) - } - - /// Serialise a constant string, allocate aligned arena space, emit InitBytes. - /// Errors if the type is not a supported primitive — no silent fallbacks. - fn emit_const( - &mut self, - raw: &str, - ty: &str, - size: usize, - align: usize, - ) -> Result { - let bytes = serialize_const(raw, ty)?; - // Use the serialised byte count as the authoritative size when it differs - // (e.g., bool serialises to 1 byte but metadata size is also 1). - let real_size = bytes.len().max(size); - let offset = self.layout.alloc(real_size, align); - self.instructions - .push(Instruction::InitBytes { offset, bytes }); - Ok(offset) - } -} - -/// Whether `kind` is one of the #654 identity producers whose output is a -/// runtime-written reference blob (`get_component_ref`, the find resolvers, -/// and `object_ref_literal`). -fn is_identity_producer(kind: CompOpKind) -> bool { - matches!( - kind, - CompOpKind::GetRef - | CompOpKind::FindByStableId - | CompOpKind::FindByName - | CompOpKind::ObjectLiteral - ) -} - -fn variable_layout_for_type(type_str: &str) -> Option<(usize, usize)> { - match type_str.trim() { - "bool" | "i8" | "u8" => Some((1, 1)), - "i16" | "u16" => Some((2, 2)), - "i32" | "u32" | "f32" | "char" => Some((4, 4)), - "i64" | "u64" | "f64" | "isize" | "usize" => Some((8, 8)), - "String" | "Vec" | "Vec<()>" => Some(( - std::mem::size_of::(), - std::mem::align_of::(), - )), - other if other.starts_with("Vec<") => Some(( - std::mem::size_of::>(), - std::mem::align_of::>(), - )), - other if other.starts_with('[') && other.ends_with(']') => parse_array_layout(other), - _ => None, - } -} - -fn parse_array_layout(type_str: &str) -> Option<(usize, usize)> { - // Very small parser for `[T; N]` used by generic shims and array variables. - let body = type_str.strip_prefix('[')?.strip_suffix(']')?; - let (inner, len_str) = body.rsplit_once(';')?; - let len: usize = len_str.trim().parse().ok()?; - let (elem_size, elem_align) = variable_layout_for_type(inner.trim())?; - let size = elem_size.saturating_mul(len); - Some((size, elem_align)) -} diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs deleted file mode 100644 index 79cde1a..0000000 --- a/src/codegen/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! # Blueprint Code Generation -//! -//! Rust code generation and bytecode generation for Blueprint graphs. - -pub mod bytecode_codegen; -#[allow(dead_code)] -mod node_handlers; -mod rust_codegen; - -pub use bytecode_codegen::BytecodeCodegen; -pub use rust_codegen::*; diff --git a/src/codegen/node_handlers.rs b/src/codegen/node_handlers.rs deleted file mode 100644 index 0ee0386..0000000 --- a/src/codegen/node_handlers.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! # Node-Specific Handlers -//! -//! Specialized code generation for different node types. -//! -//! This module provides additional utilities for handling specific -//! Blueprint node types that require special treatment. - -// Placeholder for future node-specific handlers -// For example: special handling for array operations, async nodes, etc. diff --git a/src/codegen/rust_codegen.rs b/src/codegen/rust_codegen.rs deleted file mode 100644 index 8d6a0f9..0000000 --- a/src/codegen/rust_codegen.rs +++ /dev/null @@ -1,1376 +0,0 @@ -//! # Rust Code Generator for Blueprints -//! -//! Generates Rust source code from Blueprint graphs. - -use crate::metadata::BlueprintMetadataProvider; -use graphy::core::NodeMetadataProvider; -use graphy::{ - DataResolver, ExecutionRouting, GraphDescription, GraphyError, NodeInstance, NodeTypes, -}; -use std::collections::{HashMap, HashSet}; - -/// Blueprint-specific Rust code generator -pub struct BlueprintCodeGenerator<'a> { - graph: &'a GraphDescription, - metadata_provider: &'a BlueprintMetadataProvider, - data_resolver: &'a DataResolver, - exec_routing: &'a ExecutionRouting, - variables: HashMap, - visited: HashSet, -} - -impl<'a> BlueprintCodeGenerator<'a> { - pub fn new( - graph: &'a GraphDescription, - metadata_provider: &'a BlueprintMetadataProvider, - data_resolver: &'a DataResolver, - exec_routing: &'a ExecutionRouting, - variables: HashMap, - ) -> Self { - Self { - graph, - metadata_provider, - data_resolver, - exec_routing, - variables, - visited: HashSet::new(), - } - } - - /// Generate complete Rust program from the graph - pub fn generate_program(&self) -> Result { - let mut code = String::new(); - - // Add header - code.push_str("// Auto-generated code from Pulsar Blueprint\n"); - code.push_str("// DO NOT EDIT - Changes will be overwritten\n"); - code.push_str("// Compiled with PBGC (Pulsar Blueprint Graph Compiler)\n\n"); - - // pulsar_std is imported by the enclosing `mod logic` template in project.rs; - // no additional imports needed here for the generated function body. - - if !self.variables.is_empty() { - code.push_str("use std::cell::{Cell, RefCell};\n\n"); - } - - // Collect node-specific imports - let node_imports = self.collect_node_imports(); - for import_stmt in node_imports { - code.push_str(&import_stmt); - code.push_str("\n"); - } - code.push_str("\n"); - - if !self.variables.is_empty() { - code.push_str("// PBGC_VARIABLE_STORAGE_BEGIN\n"); - code.push_str(&self.generate_variable_storage_block()?); - code.push_str("// PBGC_VARIABLE_STORAGE_END\n"); - code.push_str("\n"); - } - - // Find event nodes - let event_nodes: Vec<_> = self - .graph - .nodes - .values() - .filter(|node| { - // Custom event On nodes (node_type starts with "on_") are synthetic — - // no static metadata entry, but still treated as event entry points. - node.node_type.starts_with("on_") - || self - .metadata_provider - .get_node_metadata(&node.node_type) - .map(|meta| meta.node_type == NodeTypes::event) - .unwrap_or(false) - }) - .collect(); - - if event_nodes.is_empty() { - return Err(GraphyError::CodeGeneration( - "No event nodes found in graph - add a 'main' or 'begin_play' event".to_string(), - )); - } - - // Generate `#[pulsar_event]` structs for custom events - for event_node in &event_nodes { - if event_node.node_type.starts_with("on_") { - let event_name = - pascal_case(&event_node.node_type.strip_prefix("on_").unwrap_or("custom")); - code.push_str(&format!("#[pulsar_event]\npub struct {} {{\n", event_name)); - // Emit fields from output data pins (skip execution pins) - for pin in &event_node.outputs { - if !matches!(pin.pin.data_type, graphy::DataType::Exec) { - let rust_type = match &pin.pin.data_type { - graphy::DataType::Data(ti) => ti.type_string.clone(), - graphy::DataType::Exec => "()".to_string(), - }; - code.push_str(&format!(" pub {}: {},\n", pin.id, rust_type)); - } - } - code.push_str("}\n\n"); - } - } - - // Also collect custom event node types referenced by emit_custom_event nodes - let custom_event_structs: std::collections::HashSet = self - .graph - .nodes - .values() - .filter(|n| n.node_type == "emit_custom_event") - .filter_map(|n| n.properties.get("event_uid").and_then(|v| v.as_str())) - .map(pascal_case) - .collect(); - for struct_name in custom_event_structs { - // Ensure the struct is defined — will be generated above from the On node. - // If no On node exists in this graph, the graph structure is incomplete - // (the On node lives in a different blueprint actor). - } - - // Generate each event function - for event_node in event_nodes { - let event_code = self.generate_event_function(event_node)?; - code.push_str(&event_code); - code.push_str("\n"); - } - - Ok(code) - } - - /// Collect imports from all nodes - fn collect_node_imports(&self) -> Vec { - let mut imports: HashSet = HashSet::new(); - - for node in self.graph.nodes.values() { - if let Some(metadata) = self.metadata_provider.get_node_metadata(&node.node_type) { - for import in &metadata.imports { - imports.insert(import.clone()); - } - } - } - - let mut import_vec: Vec<_> = imports.into_iter().collect(); - import_vec.sort(); - import_vec - } - - /// Generate thread-local storage and helper functions for class variables. - fn generate_variable_storage_block(&self) -> Result { - let mut code = String::new(); - code.push_str("thread_local! {\n"); - - let mut vars: Vec<_> = self.variables.iter().collect(); - vars.sort_by(|a, b| a.0.cmp(b.0)); - - for (var_name, var_type) in vars { - let static_name = to_static_var_name(var_name); - if is_copy_type(var_type) { - code.push_str(&format!( - " static {}: Cell> = Cell::new(None);\n", - static_name, var_type - )); - } else { - code.push_str(&format!( - " static {}: RefCell> = RefCell::new(None);\n", - static_name, var_type - )); - } - } - - code.push_str("}\n\n"); - code.push_str("#[inline]\n"); - code.push_str("fn __pbgc_set_copy(slot: &Cell>, value: T) {\n"); - code.push_str(" slot.set(Some(value));\n"); - code.push_str("}\n\n"); - code.push_str("#[inline]\n"); - code.push_str( - "fn __pbgc_get_copy(slot: &Cell>, var_name: &str) -> T {\n", - ); - code.push_str(" slot.get().unwrap_or_else(|| panic!(\"PBGC variable '{}' read before assignment\", var_name))\n"); - code.push_str("}\n\n"); - code.push_str("#[inline]\n"); - code.push_str("fn __pbgc_set_clone(slot: &RefCell>, value: T) {\n"); - code.push_str(" *slot.borrow_mut() = Some(value);\n"); - code.push_str("}\n\n"); - code.push_str("#[inline]\n"); - code.push_str( - "fn __pbgc_get_clone(slot: &RefCell>, var_name: &str) -> T {\n", - ); - code.push_str(" slot.borrow()\n"); - code.push_str(" .as_ref()\n"); - code.push_str(" .cloned()\n"); - code.push_str(" .unwrap_or_else(|| panic!(\"PBGC variable '{}' read before assignment\", var_name))\n"); - code.push_str("}\n"); - - Ok(code) - } - - /// Generate an event function - fn generate_event_function(&self, event_node: &NodeInstance) -> Result { - let mut code = String::new(); - - // Every generated logic function receives the live-world slice - // (#651): the `(entity, world)` pair the enclosing `impl Actor` - // callback got from the engine. Component nodes compile to - // dispatcher calls against exactly these parameters. Types are - // fully qualified because this module only glob-imports - // `pulsar_std`/vars, which must never shadow them. - const LIVE_WORLD_PARAMS: &str = - "_entity: pulsar_game::Entity, _world: &mut pulsar_game::World"; - - // Custom event nodes don't have static metadata — infer from the node itself. - if event_node.node_type.starts_with("on_") { - let func_name = event_node.node_type.clone(); - let mut params: Vec = event_node - .outputs - .iter() - .filter(|p| !matches!(p.pin.data_type, graphy::DataType::Exec)) - .map(|p| { - let type_str = match &p.pin.data_type { - graphy::DataType::Data(ti) => ti.type_string.clone(), - graphy::DataType::Exec => "()".to_string(), - }; - format!("{}: {}", p.id, type_str) - }) - .collect(); - params.push(LIVE_WORLD_PARAMS.to_string()); - code.push_str(&format!("pub fn {}({}) {{\n", func_name, params.join(", "))); - let indent = " "; - // Generate pure node preamble - let pure_preamble = self.generate_pure_node_preamble(1)?; - if !pure_preamble.is_empty() { - code.push_str(&pure_preamble); - } - // Follow execution chain - for output_pin in &event_node.outputs { - if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self - .exec_routing - .get_connected_nodes(&event_node.id, &output_pin.id); - for next_node_id in connected { - if let Some(next_node) = self.graph.nodes.get(next_node_id) { - let mut generator = self.clone_with_new_visited(); - let node_code = generator.generate_exec_chain(next_node, 1)?; - code.push_str(&node_code); - } - } - } - } - code.push_str("}\n"); - return Ok(code); - } - - // Get event metadata (for standard event nodes) - let metadata = self - .metadata_provider - .get_node_metadata(&event_node.node_type) - .ok_or_else(|| GraphyError::NodeNotFound(event_node.node_type.clone()))?; - - // Generate function signature — include any event parameters (e.g. delta_time for on_tick), - // then the live-world slice every generated function receives (#651). - let mut params: Vec = metadata - .params - .iter() - .map(|p| format!("{}: {}", p.name, p.param_type)) - .collect(); - params.push(LIVE_WORLD_PARAMS.to_string()); - code.push_str(&format!( - "pub fn {}({}) {{\n", - metadata.name, - params.join(", ") - )); - - let pure_preamble = self.generate_pure_node_preamble(1)?; - if !pure_preamble.is_empty() { - code.push_str(&pure_preamble); - } - - // Find execution output pins and follow them - // We need to look up by pin ID (from the node instance), not pin name (from metadata) - for output_pin in &event_node.outputs { - if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - tracing::debug!( - "[CODEGEN] Looking up exec connections for node {} pin ID: {}", - event_node.id, - output_pin.id - ); - - let connected = self - .exec_routing - .get_connected_nodes(&event_node.id, &output_pin.id); - - tracing::debug!("[CODEGEN] Found {} connected nodes", connected.len()); - - for next_node_id in connected { - if let Some(next_node) = self.graph.nodes.get(next_node_id) { - let mut generator = self.clone_with_new_visited(); - let node_code = generator.generate_exec_chain(next_node, 1)?; - code.push_str(&node_code); - } - } - } - } - - code.push_str("}\n"); - - Ok(code) - } - - fn generate_pure_node_preamble(&self, indent_level: usize) -> Result { - let mut code = String::new(); - let indent = " ".repeat(indent_level); - - for node_id in self.data_resolver.get_pure_evaluation_order() { - if !self.should_materialize_pure_result(node_id) { - continue; - } - - let Some(result_var) = self.data_resolver.get_result_variable(node_id) else { - continue; - }; - - let node = self - .graph - .nodes - .get(node_id) - .ok_or_else(|| GraphyError::NodeNotFound(node_id.clone()))?; - - let expr = if let Some(var_name) = node - .node_type - .strip_prefix("get_") - .filter(|name| self.variables.contains_key(*name)) - { - let var_type = self.variables.get(var_name).ok_or_else(|| { - GraphyError::Custom(format!("Variable '{}' not found", var_name)) - })?; - let static_name = to_static_var_name(var_name); - - if is_copy_type(var_type) { - format!( - "{}.with(|v| __pbgc_get_copy(v, \"{}\"))", - static_name, var_name - ) - } else { - format!( - "{}.with(|v| __pbgc_get_clone(v, \"{}\"))", - static_name, var_name - ) - } - } else { - self.generate_pure_node_expression(node)? - }; - - code.push_str(&format!("{}let {} = {};\n", indent, result_var, expr)); - } - - Ok(code) - } - - fn should_materialize_pure_result(&self, source_node_id: &str) -> bool { - false - } - - fn pure_result_use_count(&self, source_node_id: &str) -> usize { - use graphy::analysis::DataSource; - - let mut count = 0usize; - for (consumer_id, node) in &self.graph.nodes { - for input in &node.inputs { - if let Some(DataSource::Connection { - source_node_id: sid, - .. - }) = self.data_resolver.get_input_source(consumer_id, &input.id) - { - if sid == source_node_id { - count += 1; - } - } - } - } - - count - } - - /// Generate execution chain starting from a node - fn generate_exec_chain( - &mut self, - node: &NodeInstance, - indent_level: usize, - ) -> Result { - let code = String::new(); - - // Prevent infinite loops - if self.visited.contains(&node.id) { - return Ok(code); - } - self.visited.insert(node.id.clone()); - - // ── Component nodes ─────────────────────────────────────────────────── - // Node types follow the convention: - // comp_get_prop::{ClassName}::{PropName} — pure, value producer - // comp_set_prop::{ClassName}::{PropName} — exec, value consumer - // comp_call::{ClassName}::{MethodName} — exec, optional return - - if node.node_type.starts_with("comp_get_prop::") { - // Pure node — no exec chain contribution. - return Ok(code); - } - if node.node_type.starts_with("comp_set_prop::") { - return self.generate_comp_set_prop_node(node, indent_level); - } - if node.node_type.starts_with("comp_call::") { - return self.generate_comp_call_node(node, indent_level); - } - - // ── Variable nodes ──────────────────────────────────────────────────── - if node - .node_type - .strip_prefix("get_") - .is_some_and(|name| self.variables.contains_key(name)) - { - // Getter nodes are pure (no exec chain), skip - return Ok(code); - } else if node - .node_type - .strip_prefix("set_") - .is_some_and(|name| self.variables.contains_key(name)) - { - // Setter nodes have exec chain - return self.generate_setter_node(node, indent_level); - } - - let node_meta = self - .metadata_provider - .get_node_metadata(&node.node_type) - .ok_or_else(|| GraphyError::NodeNotFound(node.node_type.clone()))?; - - match node_meta.node_type { - NodeTypes::pure => { - // Pure nodes are pre-evaluated, skip in exec chain - Ok(code) - } - NodeTypes::fn_ => self.generate_function_node(node, node_meta, indent_level), - NodeTypes::control_flow => { - self.generate_control_flow_node(node, node_meta, indent_level) - } - NodeTypes::event => { - // Event nodes define the outer function, skip in exec chain - Ok(code) - } - } - } - - // ── Component node codegen ──────────────────────────────────────────────── - // - // All three kinds compile to `pulsar_world_registry::dispatch` calls - // against the live-world parameters of the enclosing generated function - // (#651) — C's #643 dispatcher is THE one dispatch layer, with the VM's - // arena trampolines as the other adapter. Values travel in the graph's - // JSON domain exactly like the VM path: method arguments are converted - // to their declared parameter types by `json_args_to_method_args` - // before dispatch, and every failure logs and degrades to JSON null - // instead of aborting the event. - - /// Generate a component property getter expression (pure, inline). - /// - /// Node type format: `comp_get_prop::{ClassName}::{PropName}`. - /// - /// #654: when the node's `component_ref` input pin is wired to an - /// identity producer, the read targets THAT reference's actor/instance; - /// unconnected pins keep addressing the executing instance itself - /// (index 0). - fn generate_comp_get_prop_expr(&self, node: &NodeInstance) -> Result { - let rest = node - .node_type - .strip_prefix("comp_get_prop::") - .ok_or_else(|| { - GraphyError::Custom(format!("Bad comp_get_prop node: {}", node.node_type)) - })?; - let mut parts = rest.splitn(2, "::"); - let class_name = parts - .next() - .ok_or_else(|| GraphyError::Custom("Missing class name in comp_get_prop".into()))?; - let prop_name = parts - .next() - .ok_or_else(|| GraphyError::Custom("Missing prop name in comp_get_prop".into()))?; - - let target = self.generate_pin_target_expr(node, class_name)?; - Ok(format!( - r#"match {target} {{ - Some((__bp_target_entity, __bp_target_index)) => pulsar_world_registry::dispatch::get_component_property( - _world, - __bp_target_entity, - "{class_name}", - __bp_target_index, - "{prop_name}", - ) - .unwrap_or(serde_json::Value::Null), - None => serde_json::Value::Null, - }}"# - )) - } - - /// Generate the target-resolution expression for a component op's - /// optional `component_ref` input (#654). - /// - /// Unconnected pin → constant `(self entity, index 0)` pair shaped like - /// the resolved form so every op site shares one code path. Connected → - /// `resolve_pin_target(..)` against the wired reference, which logs its - /// typed failure and yields `None` on stale/lost targets (#641 - /// degrade-to-null semantics, no panics). - fn generate_pin_target_expr( - &self, - node: &NodeInstance, - class_name: &str, - ) -> Result { - use graphy::analysis::DataSource; - - match self - .data_resolver - .get_input_source(&node.id, "component_ref") - { - Some(DataSource::Connection { source_node_id, .. }) => { - let ref_expr = self.identity_producer_expr(&source_node_id)?; - Ok(format!( - "pulsar_game::script_refs::resolve_pin_target(\n\ - \x20 _world,\n\ - \x20 &({ref_expr}),\n\ - \x20 \"{class_name}\",\n\ - \x20 \"{}\",\n\ - \x20 )", - node.node_type - )) - } - _ => Ok("Some((_entity, 0))".to_string()), - } - } - - /// Generate the producing expression for an identity-reference node - /// (`get_component_ref`, `find_object_by_*`, `object_ref_literal`), - /// shared by pure-input inlining and component_ref pin resolution (#654). - fn identity_producer_expr(&self, source_node_id: &str) -> Result { - let node = self - .graph - .nodes - .get(source_node_id) - .ok_or_else(|| GraphyError::NodeNotFound(source_node_id.to_string()))?; - - if let Some(rest) = node.node_type.strip_prefix("get_component_ref::") { - let mut parts = rest.splitn(2, "::"); - let class_name = parts.next().unwrap_or_default(); - let index: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); - // Optional actor redirect: another object's actor ref. - let actor_expr = match node.inputs.iter().find(|p| p.id == "actor") { - Some(pin) => self.generate_input_expression(&node.id, &pin.id)?, - None => "_entity".to_string(), - }; - return Ok(format!( - "pulsar_game::script_refs::component_ref_json(\n\ - \x20 _world,\n\ - \x20 {actor_expr},\n\ - \x20 \"{class_name}\",\n\ - \x20 {index},\n\ - \x20 \"{}\",\n\ - \x20 )", - node.node_type - )); - } - - if node.node_type == "find_object_by_stable_id" || node.node_type == "find_object_by_name" { - let needle_pin = node - .inputs - .iter() - .find(|p| !matches!(p.pin.data_type, graphy::DataType::Exec)) - .ok_or_else(|| { - GraphyError::Custom(format!( - "identity resolver '{}' has no operand input", - node.id - )) - })?; - let needle = self.generate_input_expression(&node.id, &needle_pin.id)?; - let func = if node.node_type == "find_object_by_stable_id" { - "find_object_by_stable_id" - } else { - "find_object_by_name" - }; - return Ok(format!( - "pulsar_game::script_refs::{func}(\n\ - \x20 _world,\n\ - \x20 serde_json::to_value({needle}).unwrap_or(serde_json::Value::Null),\n\ - \x20 \"{}\",\n\ - \x20 )", - node.node_type - )); - } - - if node.node_type == "object_ref_literal" { - let stable_id = node - .properties - .get("stable_id") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let class_name = node - .properties - .get("class_name") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let component_index = node - .properties - .get("component_index") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - return Ok(format!( - "pulsar_game::script_refs::object_literal_json(\n\ - \x20 _world,\n\ - \x20 \"{stable_id}\",\n\ - \x20 \"{class_name}\",\n\ - \x20 {component_index},\n\ - \x20 \"{}\",\n\ - \x20 )", - node.node_type - )); - } - - Err(GraphyError::Custom(format!( - "Node '{}' is not an identity reference producer", - node.node_type - ))) - } - - /// Generate code for `comp_set_prop::{ClassName}::{PropName}` (exec node). - /// - /// #654: a connected `component_ref` input redirects the write to that - /// reference's `(entity, component_index)`; unconnected keeps the - /// executing instance's live-typed instance (index 0). - fn generate_comp_set_prop_node( - &mut self, - node: &NodeInstance, - indent_level: usize, - ) -> Result { - let rest = node - .node_type - .strip_prefix("comp_set_prop::") - .ok_or_else(|| { - GraphyError::Custom(format!("Bad comp_set_prop node: {}", node.node_type)) - })?; - let mut parts = rest.splitn(2, "::"); - let class_name = parts - .next() - .ok_or_else(|| GraphyError::Custom("Missing class name in comp_set_prop".into()))?; - let prop_name = parts - .next() - .ok_or_else(|| GraphyError::Custom("Missing prop name in comp_set_prop".into()))?; - - let indent = " ".repeat(indent_level); - - // Find the value input pin (never the component_ref pin). - let value_pin_id = node - .inputs - .iter() - .filter(|p| p.id != "component_ref" && p.pin.name != "component") - .find(|p| p.pin.name == "value" || !matches!(p.pin.data_type, graphy::DataType::Exec)) - .map(|p| p.id.clone()) - .ok_or_else(|| { - GraphyError::Custom(format!("No value pin on comp_set_prop node: {}", node.id)) - })?; - - let value_expr = self.generate_input_expression(&node.id, &value_pin_id)?; - let target_expr = self.generate_pin_target_expr(node, class_name)?; - - let code = format!( - r#"{indent}match {target_expr} {{ -{indent} Some((__bp_target_entity, __bp_target_index)) => {{ -{indent} if let Err(__e) = pulsar_world_registry::dispatch::set_component_property( -{indent} _world, -{indent} __bp_target_entity, -{indent} "{class_name}", -{indent} __bp_target_index, -{indent} "{prop_name}", -{indent} serde_json::to_value({value_expr}).unwrap_or(serde_json::Value::Null), -{indent} ) {{ -{indent} tracing::error!("comp_set_prop::{class_name}::{prop_name} failed: {{__e}}"); -{indent} }} -{indent} }} -{indent} None => {{}} -{indent}}} -"# - ); - - // Follow exec chain. - self.follow_exec_outputs(node, indent_level) - .map(|chain| format!("{code}{chain}")) - } - - /// Generate code for `comp_call::{ClassName}::{MethodName}` (exec node). - /// - /// #654: a connected `component_ref` input dispatches the method on that - /// reference's actor instead of the executing instance. - fn generate_comp_call_node( - &mut self, - node: &NodeInstance, - indent_level: usize, - ) -> Result { - let rest = node.node_type.strip_prefix("comp_call::").ok_or_else(|| { - GraphyError::Custom(format!("Bad comp_call node: {}", node.node_type)) - })?; - let mut parts = rest.splitn(2, "::"); - let class_name = parts - .next() - .ok_or_else(|| GraphyError::Custom("Missing class name in comp_call".into()))?; - let method_name = parts - .next() - .ok_or_else(|| GraphyError::Custom("Missing method name in comp_call".into()))?; - - let indent = " ".repeat(indent_level); - - // Collect data input arguments (skip exec AND component_ref pins), - // staged as JSON just like the VM path stages its arena blobs. - let arg_values: Vec = { - let mut exprs = Vec::new(); - for input_pin in &node.inputs { - if matches!(input_pin.pin.data_type, graphy::DataType::Exec) { - continue; - } - if input_pin.id == "component_ref" || input_pin.pin.name == "component" { - continue; - } - let expr = self.generate_input_expression(&node.id, &input_pin.id)?; - exprs.push(format!( - "serde_json::to_value({expr}).unwrap_or(serde_json::Value::Null)" - )); - } - exprs - }; - - let args_vec = if arg_values.is_empty() { - "vec![]".to_string() - } else { - format!("vec![{}]", arg_values.join(", ")) - }; - let target_expr = self.generate_pin_target_expr(node, class_name)?; - let invoke = format!( - "pulsar_world_registry::dispatch::invoke_component_method(\ - _world, __bp_target_entity, \"{class_name}\", __bp_target_index, \"{method_name}\", __args)" - ); - - let mut code = String::new(); - if has_returns_used(node) { - let result_var = format!("__comp_result_{}", &node.id[..8.min(node.id.len())]); - code.push_str(&format!( -r#"{indent}let {result_var} = match {target_expr} {{ -{indent} Some((__bp_target_entity, __bp_target_index)) => {{ -{indent} match pulsar_world_registry::dispatch::json_args_to_method_args("{class_name}", "{method_name}", {args_vec}) {{ -{indent} Ok(__args) => match {invoke} {{ -{indent} Ok(__returned) => __returned -{indent} .and_then(|__v| pulsar_world_registry::marshal::any_to_json( -{indent} "comp_call::{class_name}::{method_name} return", -{indent} __v.as_ref(), -{indent} ).ok()) -{indent} .unwrap_or(serde_json::Value::Null), -{indent} Err(__e) => {{ -{indent} tracing::error!("comp_call::{class_name}::{method_name} failed: {{__e}}"); -{indent} serde_json::Value::Null -{indent} }} -{indent} }}, -{indent} Err(__e) => {{ -{indent} tracing::error!("comp_call::{class_name}::{method_name} arguments: {{__e}}"); -{indent} serde_json::Value::Null -{indent} }} -{indent} }} -{indent} }} -{indent} None => serde_json::Value::Null, -{indent}}}; -"# - )); - } else { - code.push_str(&format!( -r#"{indent}match {target_expr} {{ -{indent} Some((__bp_target_entity, __bp_target_index)) => {{ -{indent} match pulsar_world_registry::dispatch::json_args_to_method_args("{class_name}", "{method_name}", {args_vec}) {{ -{indent} Ok(__args) => {{ -{indent} if let Err(__e) = {invoke} {{ -{indent} tracing::error!("comp_call::{class_name}::{method_name} failed: {{__e}}"); -{indent} }} -{indent} }} -{indent} Err(__e) => tracing::error!("comp_call::{class_name}::{method_name} arguments: {{__e}}"), -{indent} }} -{indent} }} -{indent} None => {{}} -{indent}}} -"# - )); - } - - // Follow exec chain. - self.follow_exec_outputs(node, indent_level) - .map(|chain| format!("{code}{chain}")) - } - - /// Emit every exec-chain successor of `node` at the same indent level. - /// - /// Shared tail of the exec-node generators (function/setter/component - /// nodes all continue their chains identically). - fn follow_exec_outputs( - &mut self, - node: &NodeInstance, - indent_level: usize, - ) -> Result { - let mut code = String::new(); - for output_pin in &node.outputs { - if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self - .exec_routing - .get_connected_nodes(&node.id, &output_pin.id); - for next_node_id in connected { - if let Some(next_node) = self.graph.nodes.get(next_node_id) { - let next_code = self.generate_exec_chain(next_node, indent_level)?; - code.push_str(&next_code); - } - } - } - } - Ok(code) - } - - /// Generate code for a function node - fn generate_function_node( - &mut self, - node: &NodeInstance, - node_meta: &graphy::core::NodeMetadata, - indent_level: usize, - ) -> Result { - let mut code = String::new(); - let indent = " ".repeat(indent_level); - - // Collect arguments - let args = self.collect_arguments(node, node_meta)?; - - // Use effective_outputs() which handles both multi-output (output_params) - // and single-output (synthesized "result" pin from return_type). - let outputs = node_meta.effective_outputs(); - let is_multi = outputs.len() > 1; - - if is_multi { - // Multi-output: store the whole tuple in a temp variable. - // Consumers reference fields via accessor (.0, .1, ...). - let tmp_var = format!("__tmp_{}", &node.id[..8.min(node.id.len())]); - code.push_str(&format!( - "{}let {} = {}({});\n", - indent, - tmp_var, - node_meta.name, - args.join(", ") - )); - } else if !outputs.is_empty() { - // Single output (the default "result" pin) - let result_var = self - .data_resolver - .get_result_variable(&node.id) - .ok_or_else(|| { - GraphyError::Custom(format!("No result variable for node: {}", node.id)) - })?; - - code.push_str(&format!( - "{}let {} = {}({});\n", - indent, - result_var, - node_meta.name, - args.join(", ") - )); - } else { - // Void return - code.push_str(&format!( - "{}{}({});\n", - indent, - node_meta.name, - args.join(", ") - )); - } - - // Follow execution chain - look up by actual pin IDs from node instance - for output_pin in &node.outputs { - if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self - .exec_routing - .get_connected_nodes(&node.id, &output_pin.id); - for next_node_id in connected { - if let Some(next_node) = self.graph.nodes.get(next_node_id) { - let next_code = self.generate_exec_chain(next_node, indent_level)?; - code.push_str(&next_code); - } - } - } - } - - Ok(code) - } - - /// Generate code for a control flow node - fn generate_control_flow_node( - &mut self, - node: &NodeInstance, - node_meta: &graphy::core::NodeMetadata, - indent_level: usize, - ) -> Result { - let mut code = String::new(); - let indent = " ".repeat(indent_level); - - // Build exec_output replacements - need to map pin names to pin IDs - let mut exec_replacements = HashMap::new(); - - for output_pin in &node.outputs { - if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self - .exec_routing - .get_connected_nodes(&node.id, &output_pin.id); - - let mut exec_code = String::new(); - let local_visited = self.visited.clone(); - - for next_node_id in connected { - if let Some(next_node) = self.graph.nodes.get(next_node_id) { - let mut sub_gen = BlueprintCodeGenerator { - graph: self.graph, - metadata_provider: self.metadata_provider, - data_resolver: self.data_resolver, - exec_routing: self.exec_routing, - variables: self.variables.clone(), - visited: local_visited.clone(), - }; - - let next_code = sub_gen.generate_exec_chain(next_node, 0)?; - exec_code.push_str(&next_code); - } - } - - // Use the pin NAME for the template substitution (e.g., "then", "else") - exec_replacements.insert(output_pin.pin.name.clone(), exec_code.trim().to_string()); - } - } - - // Build parameter substitutions - need to look up by pin ID - let mut param_substitutions = HashMap::new(); - for param in &node_meta.params { - // Find the actual pin ID from the node instance - let pin_id = node - .inputs - .iter() - .find(|input| input.pin.name == param.name) - .map(|input| input.id.clone()) - .ok_or_else(|| { - GraphyError::Custom(format!( - "Input pin not found for parameter '{}' on node '{}'", - param.name, node.id - )) - })?; - - let value = self.generate_input_expression(&node.id, &pin_id)?; - param_substitutions.insert(param.name.clone(), value); - } - - // Inline the function with substitutions - let inlined_body = graphy::utils::inline_control_flow_function( - &node_meta.function_source, - exec_replacements, - param_substitutions, - )?; - - // Add inlined code with proper indentation - for line in inlined_body.lines() { - if !line.trim().is_empty() { - code.push_str(&format!("{}{}\n", indent, line)); - } - } - - Ok(code) - } - - /// Generate code for a setter node - fn generate_setter_node( - &mut self, - node: &NodeInstance, - indent_level: usize, - ) -> Result { - let mut code = String::new(); - let indent = " ".repeat(indent_level); - - // Extract variable name from node type (remove "set_" prefix) - let var_name = node.node_type.strip_prefix("set_").ok_or_else(|| { - GraphyError::Custom(format!("Invalid setter node type: {}", node.node_type)) - })?; - - // Find the "value" input pin ID - let value_pin_id = node - .inputs - .iter() - .find(|input| input.pin.name == "value") - .map(|input| input.id.clone()) - .ok_or_else(|| { - GraphyError::Custom(format!("Value input not found on setter node: {}", node.id)) - })?; - - // Get the value to set - let value_expr = self.generate_input_expression(&node.id, &value_pin_id)?; - - // Get variable type to determine Cell vs RefCell - let var_type = self - .variables - .get(var_name) - .ok_or_else(|| GraphyError::Custom(format!("Variable '{}' not found", var_name)))?; - - let static_name = to_static_var_name(var_name); - - // Generate setter code - let is_copy_type = is_copy_type(var_type); - if is_copy_type { - code.push_str(&format!( - "{}{}.with(|v| __pbgc_set_copy(v, {}));\n", - indent, static_name, value_expr - )); - } else { - code.push_str(&format!( - "{}{}.with(|v| __pbgc_set_clone(v, {}));\n", - indent, static_name, value_expr - )); - } - - // Follow execution chain - use actual pin IDs from node instance - for output_pin in &node.outputs { - if matches!(output_pin.pin.data_type, graphy::DataType::Exec) { - let connected = self - .exec_routing - .get_connected_nodes(&node.id, &output_pin.id); - for next_node_id in connected { - if let Some(next_node) = self.graph.nodes.get(next_node_id) { - let next_code = self.generate_exec_chain(next_node, indent_level)?; - code.push_str(&next_code); - } - } - } - } - - Ok(code) - } - - /// Collect arguments for a function call - fn collect_arguments( - &self, - node: &NodeInstance, - node_meta: &graphy::core::NodeMetadata, - ) -> Result, GraphyError> { - let mut args = Vec::new(); - - for param in &node_meta.params { - // Find the actual pin ID from the node instance - // Pin IDs are typically "{node_id}_{param_name}" - let pin_id = node - .inputs - .iter() - .find(|input| { - // Match by name - the pin's name should match the param name - input.pin.name == param.name - }) - .map(|input| input.id.clone()) - .ok_or_else(|| { - GraphyError::Custom(format!( - "Input pin not found for parameter '{}' on node '{}'", - param.name, node.id - )) - })?; - - let value = self.generate_input_expression(&node.id, &pin_id)?; - args.push(value); - } - - Ok(args) - } - - /// Generate expression for an input value - /// pin_id should be the actual pin ID from the node instance (e.g., "print_1_value") - fn generate_input_expression( - &self, - node_id: &str, - pin_id: &str, - ) -> Result { - use graphy::analysis::DataSource; - - /// Resolve the output pin name on the source node for a multi-output accessor. - fn get_multi_output_accessor( - metadata_provider: &BlueprintMetadataProvider, - graph: &GraphDescription, - source_node_id: &str, - source_pin_id: &str, - ) -> Option { - let src_node = graph.nodes.get(source_node_id)?; - let src_meta = metadata_provider.get_node_metadata(&src_node.node_type)?; - // Find the pin name by matching pin instance ID - let pin_name = src_node - .outputs - .iter() - .find(|p| p.id == source_pin_id) - .map(|p| p.pin.name.as_str())?; - let outputs = src_meta.effective_outputs(); - if outputs.len() <= 1 { - return None; - } - outputs.iter().find(|o| o.name == pin_name).and_then(|o| { - if o.accessor.is_empty() { - None - } else { - Some(o.accessor.clone()) - } - }) - } - - match self.data_resolver.get_input_source(node_id, pin_id) { - Some(DataSource::Connection { - source_node_id, - source_pin, - }) => { - let source_node = self - .graph - .nodes - .get(source_node_id) - .ok_or_else(|| GraphyError::NodeNotFound(source_node_id.clone()))?; - - // Check if source is a component property getter (pure) - if source_node.node_type.starts_with("comp_get_prop::") { - return self.generate_comp_get_prop_expr(source_node); - } - - // Check if source is an identity reference producer (#654): - // get_component_ref / find_object_by_* / object_ref_literal. - if source_node - .node_type - .strip_prefix("get_component_ref::") - .is_some() - || source_node.node_type == "find_object_by_stable_id" - || source_node.node_type == "find_object_by_name" - || source_node.node_type == "object_ref_literal" - { - return self.identity_producer_expr(source_node_id); - } - - // Check if source is a variable getter - if let Some(var_name) = source_node - .node_type - .strip_prefix("get_") - .filter(|name| self.variables.contains_key(*name)) - { - let var_type = self.variables.get(var_name).ok_or_else(|| { - GraphyError::Custom(format!("Variable '{}' not found", var_name)) - })?; - - let static_name = to_static_var_name(var_name); - - let is_copy = is_copy_type(var_type); - return if is_copy { - Ok(format!( - "{}.with(|v| __pbgc_get_copy(v, \"{}\"))", - static_name, var_name - )) - } else { - Ok(format!( - "{}.with(|v| __pbgc_get_clone(v, \"{}\"))", - static_name, var_name - )) - }; - } - - // Check if source is pure. Prefer precomputed temporary results - // to avoid duplicating pure function calls across downstream uses. - if let Some(node_meta) = self - .metadata_provider - .get_node_metadata(&source_node.node_type) - { - if node_meta.node_type == NodeTypes::pure { - if self.should_materialize_pure_result(source_node_id) { - if let Some(var_name) = - self.data_resolver.get_result_variable(source_node_id) - { - // Check for multi-output accessor - if let Some(acc) = get_multi_output_accessor( - self.metadata_provider, - self.graph, - source_node_id, - &source_pin, - ) { - return Ok(format!("{}{}", var_name, acc)); - } - return Ok(var_name.clone()); - } - } - let expr = self.generate_pure_node_expression(source_node)?; - // Check for multi-output accessor on the inline expression - if let Some(acc) = get_multi_output_accessor( - self.metadata_provider, - self.graph, - source_node_id, - &source_pin, - ) { - return Ok(format!("({}){}", expr, acc)); - } - return Ok(expr); - } - } - - // Non-pure: use result variable - if let Some(var_name) = self.data_resolver.get_result_variable(source_node_id) { - // Check for multi-output accessor - if let Some(acc) = get_multi_output_accessor( - self.metadata_provider, - self.graph, - source_node_id, - &source_pin, - ) { - Ok(format!("{}{}", var_name, acc)) - } else { - Ok(var_name.clone()) - } - } else { - Err(GraphyError::Custom(format!( - "No variable for source node: {}", - source_node_id - ))) - } - } - Some(DataSource::Constant(value)) => Ok(value.clone()), - Some(DataSource::Default) => { - // Use default value for the type - if let Some(node) = self.graph.nodes.get(node_id) { - if let Some(pin) = node.inputs.iter().find(|p| p.id == pin_id) { - Ok(get_default_value(&pin.pin.data_type)) - } else { - Err(GraphyError::PinNotFound { - node: node_id.to_string(), - pin: pin_id.to_string(), - }) - } - } else { - Err(GraphyError::NodeNotFound(node_id.to_string())) - } - } - None => Err(GraphyError::Custom(format!( - "No data source for input: {}.{}", - node_id, pin_id - ))), - } - } - - /// Generate inlined expression for a pure node - fn generate_pure_node_expression(&self, node: &NodeInstance) -> Result { - let node_meta = self - .metadata_provider - .get_node_metadata(&node.node_type) - .ok_or_else(|| GraphyError::NodeNotFound(node.node_type.clone()))?; - - // Recursively generate arguments - let mut args = Vec::new(); - for param in &node_meta.params { - // Find the actual pin ID from the node instance - let pin_id = node - .inputs - .iter() - .find(|input| input.pin.name == param.name) - .map(|input| input.id.clone()) - .ok_or_else(|| { - GraphyError::Custom(format!( - "Input pin not found for parameter '{}' on node '{}'", - param.name, node.id - )) - })?; - - let arg_expr = self.generate_input_expression(&node.id, &pin_id)?; - args.push(arg_expr); - } - - Ok(format!("{}({})", node_meta.name, args.join(", "))) - } - - /// Clone with new visited set - fn clone_with_new_visited(&self) -> Self { - Self { - graph: self.graph, - metadata_provider: self.metadata_provider, - data_resolver: self.data_resolver, - exec_routing: self.exec_routing, - variables: self.variables.clone(), - visited: HashSet::new(), - } - } -} - -/// Check if a type is Copy (uses Cell) or not (uses RefCell) -fn is_copy_type(type_str: &str) -> bool { - matches!( - type_str, - "i32" - | "i64" - | "u32" - | "u64" - | "f32" - | "f64" - | "bool" - | "char" - | "usize" - | "isize" - | "i8" - | "i16" - | "u8" - | "u16" - ) -} - -fn to_static_var_name(var_name: &str) -> String { - let mut out = String::from("PBGC_VAR_"); - - for ch in var_name.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_uppercase()); - } else { - out.push('_'); - } - } - - if out.ends_with('_') { - out.push('X'); - } - - out -} - -/// Check whether any downstream data consumer reads the result of this node. -/// -/// Used to decide whether to emit a `let` binding for a component call result. -fn has_returns_used(node: &NodeInstance) -> bool { - node.outputs - .iter() - .any(|p| !matches!(p.pin.data_type, graphy::DataType::Exec)) -} - -/// Get default value for a data type -fn get_default_value(data_type: &graphy::DataType) -> String { - use graphy::DataType; - - match data_type { - DataType::Exec => "()".to_string(), - DataType::Data(ti) => graphy::utils::get_default_value_for_type(&ti.type_string), - } -} - -/// Convert a kebab-case or snake_case string to PascalCase. -fn pascal_case(name: &str) -> String { - name.split(|c: char| c == '_' || c == '-') - .filter(|s| !s.is_empty()) - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => { - let mut s = first.to_uppercase().to_string(); - s.push_str(chars.as_str()); - s - } - } - }) - .collect() -} diff --git a/src/compiler.rs b/src/compiler.rs deleted file mode 100644 index 20f8065..0000000 --- a/src/compiler.rs +++ /dev/null @@ -1,379 +0,0 @@ -//! # Blueprint Compiler -//! -//! Main entry points for compiling Blueprint graphs to Rust code. - -use crate::bytecode::comp_ops::ComponentOpRef; -use crate::bytecode::BpProgram; -use crate::codegen::{BlueprintCodeGenerator, BytecodeCodegen}; -use crate::metadata::{get_node_metadata, BlueprintMetadataProvider}; -use graphy::{DataResolver, ExecutionRouting, GraphDescription, GraphyError}; -use std::collections::HashMap; - -/// Compile a Blueprint graph to Rust source code -/// -/// This is the main entry point for the Blueprint compiler. It takes a visual node graph -/// and generates equivalent Rust source code that can be compiled and executed. -/// -/// # Arguments -/// -/// * `graph` - The Blueprint graph to compile -/// -/// # Returns -/// -/// * `Ok(String)` - The generated Rust source code -/// * `Err(GraphyError)` - A descriptive error if compilation fails -/// -/// # Examples -/// -/// ```rust,no_run -/// use pbgc::compile_graph; -/// use graphy::GraphDescription; -/// -/// let graph = GraphDescription::new("test"); -/// match compile_graph(&graph) { -/// Ok(code) => println!("Generated:\n{}", code), -/// Err(e) => eprintln!("Error: {}", e), -/// } -/// ``` -pub fn compile_graph(graph: &GraphDescription) -> Result { - compile_graph_with_library_manager(graph, None) -} - -/// Compile a Blueprint graph with sub-graph expansion support -/// -/// This extended version of `compile_graph` supports expanding sub-graph instances -/// before compilation. Sub-graphs are Blueprint macros that can be instantiated -/// multiple times within a graph. -/// -/// # Arguments -/// -/// * `graph` - The Blueprint graph to compile -/// * `library_manager` - Optional library manager providing sub-graph definitions -/// -/// # Returns -/// -/// * `Ok(String)` - The generated Rust source code -/// * `Err(GraphyError)` - A descriptive error if compilation fails -pub fn compile_graph_with_library_manager( - graph: &GraphDescription, - _library_manager: Option<()>, // TODO: Define LibraryManager type -) -> Result { - tracing::info!("[PBGC] Starting Blueprint compilation"); - tracing::info!( - "[PBGC] Graph: {} ({} nodes, {} connections)", - graph.metadata.name, - graph.nodes.len(), - graph.connections.len() - ); - - // Create a mutable copy for expansion - let expanded_graph = graph.clone(); - - // Phase 0: Expand sub-graphs if library manager is provided - // TODO: Implement sub-graph expansion - // if let Some(lib_manager) = library_manager { - // tracing::info!("[PBGC] Phase 0: Expanding sub-graphs..."); - // expander.expand_all(&mut expanded_graph)?; - // } - - // Phase 1: Get node metadata - tracing::info!("[PBGC] Phase 1: Loading node metadata..."); - let metadata_provider = BlueprintMetadataProvider::new(); - tracing::info!("[PBGC] Loaded {} node types", get_node_metadata().len()); - - // Phase 2: Build data flow resolver - tracing::info!("[PBGC] Phase 2: Analyzing data flow..."); - let data_resolver = DataResolver::build(&expanded_graph, &metadata_provider)?; - tracing::info!("[PBGC] Data flow analysis complete"); - tracing::info!( - "[PBGC] - {} pure nodes in evaluation order", - data_resolver.get_pure_evaluation_order().len() - ); - - // Phase 3: Build execution routing - tracing::info!("[PBGC] Phase 3: Analyzing execution flow..."); - let exec_routing = ExecutionRouting::build_from_graph(&expanded_graph); - tracing::info!("[PBGC] Execution flow analysis complete"); - - // Phase 4: Generate code - tracing::info!("[PBGC] Phase 4: Generating Rust code..."); - let variables = HashMap::new(); - let code_generator = BlueprintCodeGenerator::new( - &expanded_graph, - &metadata_provider, - &data_resolver, - &exec_routing, - variables, - ); - let code = code_generator.generate_program()?; - - tracing::info!("[PBGC] Code generation complete ({} bytes)", code.len()); - tracing::info!("[PBGC] Compilation successful!"); - - Ok(code) -} - -/// Compile a graph with class variables -/// -/// This variant supports Blueprint classes with member variables. The variables -/// are generated with appropriate thread-safe wrappers (Cell/RefCell + Arc). -/// -/// # Arguments -/// -/// * `graph` - The Blueprint graph to compile -/// * `variables` - Map of variable names to their Rust types -/// -/// # Returns -/// -/// * `Ok(String)` - The generated Rust source code including variable declarations -/// * `Err(GraphyError)` - A descriptive error if compilation fails -pub fn compile_graph_with_variables( - graph: &GraphDescription, - variables: HashMap, -) -> Result { - tracing::info!("[PBGC] Compiling with {} class variables", variables.len()); - - let metadata_provider = BlueprintMetadataProvider::new(); - let data_resolver = DataResolver::build(&graph, &metadata_provider)?; - let exec_routing = ExecutionRouting::build_from_graph(&graph); - - let code_generator = BlueprintCodeGenerator::new( - &graph, - &metadata_provider, - &data_resolver, - &exec_routing, - variables, - ); - - code_generator.generate_program() -} - -/// Compile a Blueprint graph to bytecode programs (one per event entry-point). -/// -/// This is the second compile target alongside `compile_graph`. -/// The resulting `BpProgram`s are fed to `BytecodeVm::run()` using any `NodeDispatch` -/// implementation — typically the engine's `pulsar_bp_executor` backed by the native cdylib. -/// -/// # Example -/// -/// ```rust,no_run -/// use pbgc::compile_graph_to_bytecode; -/// use graphy::GraphDescription; -/// -/// let graph = GraphDescription::new("my_blueprint"); -/// let programs = compile_graph_to_bytecode(&graph).unwrap(); -/// for prog in &programs { -/// println!("event '{}': {} instructions", prog.name, prog.instructions.len()); -/// } -/// ``` -pub fn compile_graph_to_bytecode(graph: &GraphDescription) -> Result, GraphyError> { - compile_graph_to_bytecode_with_variables(graph, HashMap::new()) -} - -/// Compile a Blueprint graph to bytecode programs with class variables. -pub fn compile_graph_to_bytecode_with_variables( - graph: &GraphDescription, - variables: HashMap, -) -> Result, GraphyError> { - compile_graph_to_bytecode_full(graph, variables).map(|out| out.programs) -} - -/// A full bytecode compilation: the event programs plus every component -/// operation they reference (see [`crate::bytecode::comp_ops`]). -#[derive(Debug, Clone)] -pub struct BytecodeCompilation { - pub programs: Vec, - /// Deduplicated `(kind, class, member)` triples across all programs. - pub components: Vec, -} - -/// Like [`compile_graph_to_bytecode_with_variables`], but also reports the -/// referenced component operations so runtimes can validate availability and -/// pre-resolve reflection metadata before executing events. -pub fn compile_graph_to_bytecode_full( - graph: &GraphDescription, - variables: HashMap, -) -> Result { - tracing::info!("[PBGC] Starting bytecode compilation"); - tracing::info!( - "[PBGC] Graph: {} ({} nodes, {} connections)", - graph.metadata.name, - graph.nodes.len(), - graph.connections.len() - ); - - let metadata_provider = BlueprintMetadataProvider::new(); - let data_resolver = DataResolver::build(graph, &metadata_provider)?; - let exec_routing = ExecutionRouting::build_from_graph(graph); - - let mut codegen = BytecodeCodegen::new( - graph, - &metadata_provider, - &data_resolver, - &exec_routing, - variables, - ); - let programs = codegen.generate_programs()?; - let components = codegen.component_refs().to_vec(); - - tracing::info!( - "[PBGC] Bytecode compilation complete ({} programs, {} component ops)", - programs.len(), - components.len() - ); - Ok(BytecodeCompilation { - programs, - components, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use graphy::{Connection, ConnectionType, DataType, Pin, PinInstance, PinType, Position}; - use std::collections::HashSet; - - #[test] - fn branch_condition_from_pure_chain_inlines_single_use_pure_nodes() { - let mut graph = GraphDescription::new("branch_pure_chain"); - - let mut begin = - graphy::NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); - begin.outputs.push(PinInstance::new( - "begin_exec", - Pin::new("begin_exec", "Body", DataType::Exec, PinType::Output), - )); - - let mut add = graphy::NodeInstance::new("add_node", "add", Position { x: 100.0, y: 0.0 }); - add.inputs.push(PinInstance::new( - "add_a", - Pin::new("add_a", "a", DataType::typed("f64"), PinType::Input), - )); - add.inputs.push(PinInstance::new( - "add_b", - Pin::new("add_b", "b", DataType::typed("f64"), PinType::Input), - )); - add.outputs.push(PinInstance::new( - "add_result", - Pin::new( - "add_result", - "result", - DataType::typed("f64"), - PinType::Output, - ), - )); - add.properties - .insert("add_a".to_string(), serde_json::json!(1.0)); - add.properties - .insert("add_b".to_string(), serde_json::json!(3.0)); - - let mut gt = - graphy::NodeInstance::new("gt_node", "greater_than", Position { x: 200.0, y: 0.0 }); - gt.inputs.push(PinInstance::new( - "gt_a", - Pin::new("gt_a", "a", DataType::typed("f64"), PinType::Input), - )); - gt.inputs.push(PinInstance::new( - "gt_b", - Pin::new("gt_b", "b", DataType::typed("f64"), PinType::Input), - )); - gt.outputs.push(PinInstance::new( - "gt_result", - Pin::new( - "gt_result", - "result", - DataType::typed("bool"), - PinType::Output, - ), - )); - gt.properties - .insert("gt_b".to_string(), serde_json::json!(3.0)); - - let mut branch = - graphy::NodeInstance::new("branch_node", "branch", Position { x: 300.0, y: 0.0 }); - branch.inputs.push(PinInstance::new( - "branch_exec", - Pin::new("branch_exec", "exec", DataType::Exec, PinType::Input), - )); - branch.inputs.push(PinInstance::new( - "branch_condition", - Pin::new( - "branch_condition", - "condition", - DataType::typed("bool"), - PinType::Input, - ), - )); - branch.outputs.push(PinInstance::new( - "branch_true", - Pin::new("branch_true", "True", DataType::Exec, PinType::Output), - )); - branch.outputs.push(PinInstance::new( - "branch_false", - Pin::new("branch_false", "False", DataType::Exec, PinType::Output), - )); - - graph.add_node(begin); - graph.add_node(add); - graph.add_node(gt); - graph.add_node(branch); - - graph.add_connection(Connection::new( - "begin", - "begin_exec", - "branch_node", - "branch_exec", - ConnectionType::Execution, - )); - graph.add_connection(Connection::new( - "add_node", - "add_result", - "gt_node", - "gt_a", - ConnectionType::Data, - )); - graph.add_connection(Connection::new( - "gt_node", - "gt_result", - "branch_node", - "branch_condition", - ConnectionType::Data, - )); - - let code = compile_graph(&graph).expect("branch graph should compile"); - - assert!(!code.contains("let node_add_node_result")); - assert!(!code.contains("let node_gt_node_result")); - assert!(code.contains("if greater_than")); - - let mut declared = HashSet::new(); - for line in code.lines() { - let trimmed = line.trim_start(); - if let Some(rest) = trimmed.strip_prefix("let ") { - if let Some(ident) = rest.split_whitespace().next() { - let ident = ident.trim_end_matches('='); - declared.insert(ident.to_string()); - } - } - } - - for line in code.lines() { - let trimmed = line.trim_start(); - if let Some(rest) = trimmed.strip_prefix("if ") { - let cond_ident = rest - .split(|c: char| c.is_whitespace() || c == '{' || c == '(') - .next() - .unwrap_or(""); - - if cond_ident.starts_with("node_") && cond_ident.ends_with("_result") { - assert!( - declared.contains(cond_ident), - "branch condition references undeclared temporary: {}\nGenerated code:\n{}", - cond_ident, - code - ); - } - } - } - } -} diff --git a/src/disk.rs b/src/disk.rs deleted file mode 100644 index e342cdb..0000000 --- a/src/disk.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Disk-level blueprint scanning and compilation. -//! -//! Walks a project root looking for `graph_save.json` files, compiles each -//! one with PBGC, and returns the results as [`crate::project::CompiledBlueprint`]s -//! ready to be handed to [`crate::project::generate_project`]. - -use std::path::{Path, PathBuf}; - -use crate::compiler::{compile_graph, compile_graph_with_variables}; -use crate::project::{generate_project, CompiledBlueprint, GeneratedProject, ProjectSpec}; -use graphy::{DataType, GraphDescription, TypeInfo}; -use serde::Deserialize; -use std::collections::HashMap; - -#[derive(Debug, Deserialize)] -struct BlueprintAssetFile { - #[serde(alias = "mainGraph")] - main_graph: GraphDescription, - #[serde(default)] - variables: Vec, -} - -#[derive(Debug, Deserialize)] -struct ClassVariableFile { - name: String, - data_type: DataType, - #[serde(default)] - default_value: Option, -} - -/// Compile every blueprint found under `project_root`. -/// -/// Recurses through the directory tree looking for folders that contain a -/// `graph_save.json` file. Each one is compiled with PBGC. Folders that -/// fail to compile are logged and skipped rather than aborting the whole run. -/// -/// Returns `Err` only if the root path cannot be read at all. -pub fn compile_project(project_root: &Path) -> Result, String> { - let folders = find_blueprint_folders(project_root); - let mut blueprints = Vec::new(); - - for folder in &folders { - match compile_blueprint_folder(folder) { - Ok(compiled) => { - blueprints.push(compiled); - } - Err(e) => { - tracing::warn!( - "[PBGC] Skipping blueprint folder {}: {}", - folder.display(), - e - ); - } - } - } - - Ok(blueprints) -} - -/// Compile all blueprints under `project_root` and return generated class files. -/// -/// This ensures output contains actor structs and `#[derive(EngineClass)]` -/// wrappers instead of returning only raw PBGC logic. -pub fn compile_project_generated(project_root: &Path) -> Result { - let crate_name = project_root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("pulsar_project") - .replace([' ', '-'], "_") - .to_ascii_lowercase(); - - let compiled = compile_project(project_root)?; - - let mut spec = ProjectSpec::new(crate_name).description("Generated by PBGC"); - for bp in compiled { - spec = spec.add_blueprint(bp); - } - - Ok(generate_project(&spec)) -} - -/// Compile a single blueprint folder that contains `graph_save.json`. -fn compile_blueprint_folder(folder: &Path) -> Result { - let graph_file = folder.join("graph_save.json"); - let json = std::fs::read_to_string(&graph_file) - .map_err(|e| format!("Cannot read {}: {}", graph_file.display(), e))?; - - let blueprint_name = folder - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed_blueprint") - .to_owned(); - - if let Ok(asset) = serde_json::from_str::(&json) { - let variables = compiled_variables(&asset.variables); - let source = if variables.is_empty() { - compile_graph(&asset.main_graph).map_err(|e| format!("Compilation error: {e}"))? - } else { - let variable_types = variables - .iter() - .map(|var| (var.name.clone(), var.rust_type.clone())) - .collect::>(); - - compile_graph_with_variables(&asset.main_graph, variable_types) - .map_err(|e| format!("Compilation error: {e}"))? - }; - - return Ok(CompiledBlueprint::new(blueprint_name, source).with_variables(variables)); - } - - let graph: graphy::GraphDescription = serde_json::from_str(&json) - .map_err(|e| format!("Cannot parse {}: {}", graph_file.display(), e))?; - - let source = compile_graph(&graph).map_err(|e| format!("Compilation error: {e}"))?; - Ok(CompiledBlueprint::new(blueprint_name, source)) -} - -fn compiled_variables(variables: &[ClassVariableFile]) -> Vec { - variables - .iter() - .map(|var| crate::project::CompiledVariable { - name: var.name.clone(), - rust_type: rust_type_for_data_type(&var.data_type), - default_value: var.default_value.clone(), - }) - .collect() -} - -fn rust_type_for_data_type(data_type: &DataType) -> String { - match data_type { - DataType::Data(ti) => ti.type_string.clone(), - DataType::Exec => "()".to_string(), - } -} - -/// Recursively find every directory that contains a `graph_save.json`. -fn find_blueprint_folders(root: &Path) -> Vec { - let mut result = Vec::new(); - walk_for_blueprints(root, &mut result); - result -} - -fn walk_for_blueprints(dir: &Path, out: &mut Vec) { - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if path.join("graph_save.json").exists() { - out.push(path.clone()); - } - walk_for_blueprints(&path, out); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use graphy::{ - DataType, GraphDescription, NodeInstance, Pin, PinInstance, PinType, Position, TypeInfo, - }; - - fn unique_temp_dir(prefix: &str) -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("{}_{}", prefix, nanos)) - } - - #[test] - fn compile_project_reads_blueprint_asset_variables() { - let root = unique_temp_dir("pbgc_disk_asset_vars"); - let class_dir = root.join("PlayerClass"); - std::fs::create_dir_all(&class_dir).unwrap(); - - let mut graph = GraphDescription::new("player_graph"); - let mut begin = NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); - begin.outputs.push(PinInstance::new( - "begin_exec", - Pin::new("begin_exec", "Body", DataType::Exec, PinType::Output), - )); - graph.add_node(begin); - let graph_value = serde_json::to_value(&graph).unwrap(); - let asset_value = serde_json::json!({ - "format_version": 1, - "mainGraph": graph_value, - "variables": [ - { - "id": "var_1", - "name": "health", - "data_type": graphy::DataType::typed("f64"), - "default_value": "100.0", - "description": "" - } - ] - }); - - std::fs::write( - class_dir.join("graph_save.json"), - serde_json::to_string_pretty(&asset_value).unwrap(), - ) - .unwrap(); - - let compiled_single = compile_blueprint_folder(&class_dir).unwrap(); - assert_eq!(compiled_single.variables.len(), 1); - assert_eq!(compiled_single.variables[0].name, "health"); - - let compiled = compile_project(&root).unwrap(); - assert_eq!(compiled.len(), 1); - assert_eq!(compiled[0].name, "PlayerClass"); - assert_eq!(compiled[0].variables.len(), 1); - assert_eq!(compiled[0].variables[0].name, "health"); - - let generated = compile_project_generated(&root).unwrap(); - assert!(generated - .files - .contains_key("src/classes/player_class/mod.rs")); - assert!(generated - .files - .contains_key("src/classes/player_class/events/mod.rs")); - assert!(generated - .files - .contains_key("src/classes/player_class/events/events.rs")); - assert!(generated - .files - .contains_key("src/classes/player_class/vars/mod.rs")); - - let _ = std::fs::remove_dir_all(&root); - } -} diff --git a/src/lib.rs b/src/lib.rs index 16197ce..3228073 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,39 +1,18 @@ -pub mod bytecode; -pub mod codegen; -pub mod compiler; -pub mod disk; -pub mod metadata; -pub mod project; -pub mod vm; - -// Rust source codegen -pub use compiler::{ - compile_graph, compile_graph_to_bytecode, compile_graph_to_bytecode_full, - compile_graph_to_bytecode_with_variables, compile_graph_with_library_manager, - compile_graph_with_variables, BytecodeCompilation, -}; - -// Bytecode types -pub use bytecode::comp_ops::{parse_node_type, CompOpKind, ComponentOpRef}; -pub use bytecode::{BpProgram, Instruction, LabelId}; +//! # PBGC: the pulsar_std node catalogue for Graphy +//! +//! The Blueprint graph compiler used to live here, with a bytecode VM and a +//! graph-to-Rust backend. Both are gone: Blueprint graphs now compile to +//! language-neutral script modules (`blueprint_compiler`, in the Blueprint +//! plugin) that the engine's script VM runs, and the Rust export generates +//! its code from that same module (`pulsar_script_codegen`). +//! +//! What remains is the bridge between `pulsar_std`'s `#[blueprint]` registry +//! and Graphy's node metadata: palettes, pin shapes and graph validation read +//! the standard library's nodes through it. -// VM execution APIs -pub use vm::{run, run_with_external_arena, DispatchFn, VmError}; - -// Disk / project -pub use disk::{compile_project, compile_project_generated}; -pub use project::{ - compile_graph_to_actor_source, generate_blueprint_actor_source, - generate_blueprint_actor_source_with_components, generate_project, CompiledBlueprint, - CompiledComponent, CompiledVariable, GeneratedProject, ProjectSpec, -}; +pub mod metadata; -// Graphy re-exports -pub use graphy::core::TypeInfo; -pub use graphy::{ - Connection, ConnectionType, DataType, GraphDescription, GraphMetadata, GraphyError, JsonValue, - NodeInstance, NodeMetadata, NodeMetadataProvider, NodeTypes, Pin, PinInstance, PinType, - Position, Result, +pub use metadata::{ + extract_node_metadata, find_node, get_categories, get_node_metadata, get_nodes_by_category, + BlueprintMetadataProvider, }; - -pub use metadata::{extract_node_metadata, BlueprintMetadataProvider}; diff --git a/src/metadata.rs b/src/metadata.rs index 42cae96..c600cc6 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -134,42 +134,16 @@ pub fn get_node_metadata() -> &'static HashMap { /// Blueprint metadata provider /// /// Implements the `NodeMetadataProvider` trait for Blueprint nodes. -/// Also exposes compile-time layout data (size/align) sourced directly from the -/// `#[blueprint]` macro via `pulsar_std::NodeParameter` — no type-string matching. pub struct BlueprintMetadataProvider { metadata: &'static HashMap, - /// Raw pulsar_std metadata, keyed by node name. Provides authoritative size/align. - raw: HashMap, } impl BlueprintMetadataProvider { pub fn new() -> Self { - let raw: HashMap = pulsar_std::get_all_nodes() - .iter() - .map(|m| (m.name.to_string(), m)) - .collect(); Self { metadata: get_node_metadata(), - raw, } } - - /// Returns (size, align) for a named input parameter of a node. - /// Sourced from `size_of::()` / `align_of::()` baked in by the `#[blueprint]` macro. - pub fn param_layout(&self, node_type: &str, param_name: &str) -> Option<(usize, usize)> { - let raw = self.raw.get(node_type)?; - raw.params - .iter() - .find(|p| p.name == param_name) - .map(|p| (p.size, p.align)) - } - - /// Returns (size, align) for the return value of a node. - /// Returns (0, 1) for void-returning nodes. - pub fn return_layout(&self, node_type: &str) -> Option<(usize, usize)> { - let raw = self.raw.get(node_type)?; - Some((raw.return_size, raw.return_align)) - } } impl Default for BlueprintMetadataProvider { diff --git a/src/project.rs b/src/project.rs deleted file mode 100644 index f98309c..0000000 --- a/src/project.rs +++ /dev/null @@ -1,913 +0,0 @@ -//! Blueprint output generator. -//! -//! Turns a collection of compiled blueprints into blueprint-owned Rust source -//! files only. Core project/bootstrap files (`Cargo.toml`, `main.rs`) are -//! intentionally out of scope and must be handled by the core build system. -//! -//! # Pinned-trait contract (#652) -//! -//! The emitted `impl Actor` block must match the PINNED `pulsar_scenedb::Actor` -//! trait exactly (currently: `begin_play`/`end_play`/time-free `tick`, all -//! `(entity, world)`). SceneDB is pinned by rev from Pulsar-Native's root -//! manifest, so this vendored copy cannot see API drift at compile time — a -//! mismatch surfaces only as E0053 inside every generated game project. Two -//! guards hold that line: -//! -//! 1. `pulsar_game`'s build script generates probe actors through THIS module -//! and compiles them against the real pinned crate -//! (`pulsar_game::blueprint_codegen_drift`); any drift fails -//! `cargo test -p pulsar_game`. -//! 2. `just ci-drift-check` additionally generates a full game project into a -//! temp dir and runs `cargo check` on it against current pins. -//! -//! # Live-world component routing (#651) -//! -//! Generated actors carry NO private component state. Every `comp_*` graph -//! node compiles to a `pulsar_world_registry::dispatch::*` call against the -//! `(entity, world)` pair every `Actor` callback receives — the SAME -//! dispatcher the VM path uses, so mutations land in SceneDB and fire its -//! subscription/GPU events exactly like properties-panel edits. Prefab -//! component declarations hydrate onto the actor's own scene entity at -//! `begin_play`, and only when the scene hasn't already provided the -//! component (per-instance overrides win over baked-in defaults). -//! -//! ## Updating the vendored copy / the pin -//! -//! When pinned SceneDB changes an `Actor` signature: update upstream PBGC, -//! pull it INTO this vendored copy (`git subtree pull` or re-vendor), adjust -//! the emission here in the same change, bump the root-manifest rev, then run -//! both guards above. Never edit only one side — codegen and pins move -//! together, and the guards exist so forgetting fails CI instead of user -//! projects. - -use std::collections::BTreeMap; -use std::path::Path; - -use graphy::{GraphDescription, GraphyError}; - -use crate::compile_graph; - -const VARIABLE_STORAGE_BEGIN: &str = "// PBGC_VARIABLE_STORAGE_BEGIN"; -const VARIABLE_STORAGE_END: &str = "// PBGC_VARIABLE_STORAGE_END"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn to_snake_case(name: &str) -> String { - let mut out = String::with_capacity(name.len() + 4); - let mut prev_upper = false; - for (i, ch) in name.char_indices() { - if ch.is_uppercase() { - if i != 0 && !prev_upper { - out.push('_'); - } - out.push(ch.to_ascii_lowercase()); - prev_upper = true; - } else if ch == '-' || ch == ' ' { - out.push('_'); - prev_upper = false; - } else { - out.push(ch); - prev_upper = false; - } - } - out -} - -fn to_pascal_case(name: &str) -> String { - name.split(|c: char| c == '_' || c == '-' || c == ' ') - .filter(|s| !s.is_empty()) - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => { - let mut s = first.to_uppercase().to_string(); - s.push_str(chars.as_str()); - s - } - } - }) - .collect() -} - -// ── CompiledBlueprint ───────────────────────────────────────────────────────── - -/// Represents a single component instance baked into a compiled blueprint. -/// -/// The `class_name` must match a class in the reflection registry. -/// `property_defaults` is raw JSON mirroring the prefab asset's component data. -#[derive(Debug, Clone)] -pub struct CompiledComponent { - /// Class name as registered in `pulsar_reflection::REGISTRY`. - pub class_name: String, - /// JSON property overrides from the prefab sidecar. - pub property_defaults: serde_json::Value, - /// Whether this component is enabled. - pub enabled: bool, -} - -/// A blueprint that has already been compiled to Rust source by PBGC. -#[derive(Debug, Clone)] -pub struct CompiledBlueprint { - /// Original name as given to the blueprint graph. - pub name: String, - /// Rust source emitted by the blueprint compiler. - pub source: String, - /// Whether this blueprint has a `tick` event entry point in its source. - pub has_tick: bool, - /// Whether this blueprint has a `begin_play` event entry point in its source. - pub has_begin_play: bool, - /// Class variables declared by the blueprint asset. - pub variables: Vec, - /// Component instances attached to this prefab (from the prefab sidecar). - pub components: Vec, -} - -#[derive(Debug, Clone)] -pub struct CompiledVariable { - pub name: String, - pub rust_type: String, - pub default_value: Option, -} - -impl CompiledBlueprint { - /// Create from a name and compiled source, auto-detecting event entry points. - pub fn new(name: impl Into, source: impl Into) -> Self { - let source = source.into(); - let has_tick = source.contains("fn tick") || source.contains("fn on_tick"); - let has_begin_play = - source.contains("fn begin_play") || source.contains("fn on_begin_play"); - Self { - name: name.into(), - source, - has_tick, - has_begin_play, - variables: Vec::new(), - components: Vec::new(), - } - } - - pub fn with_tick(mut self, has_tick: bool) -> Self { - self.has_tick = has_tick; - self - } - - pub fn with_begin_play(mut self, has_begin_play: bool) -> Self { - self.has_begin_play = has_begin_play; - self - } - - pub fn with_variables(mut self, variables: Vec) -> Self { - self.variables = variables; - self - } - - pub fn with_components(mut self, components: Vec) -> Self { - self.components = components; - self - } -} - -// ── ProjectSpec ─────────────────────────────────────────────────────────────── - -/// Everything needed to generate blueprint output files. -pub struct ProjectSpec { - pub name: String, - pub version: String, - pub description: String, - pub blueprints: Vec, -} - -impl ProjectSpec { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - version: "0.1.0".into(), - description: String::new(), - blueprints: Vec::new(), - } - } - - pub fn version(mut self, v: impl Into) -> Self { - self.version = v.into(); - self - } - - pub fn description(mut self, d: impl Into) -> Self { - self.description = d.into(); - self - } - - pub fn add_blueprint(mut self, bp: CompiledBlueprint) -> Self { - self.blueprints.push(bp); - self - } -} - -// ── GeneratedProject ────────────────────────────────────────────────────────── - -/// The output of [`generate_project`] — blueprint files ready to write to disk. -pub struct GeneratedProject { - /// Map of relative file path → file content. - pub files: BTreeMap, -} - -impl GeneratedProject { - /// Write every file to `/`, creating directories as needed. - pub fn write_to_dir(&self, dir: impl AsRef) -> std::io::Result<()> { - let base = dir.as_ref(); - for (rel_path, content) in &self.files { - let full = base.join(rel_path); - if let Some(parent) = full.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&full, content)?; - } - Ok(()) - } - - pub fn file_paths(&self) -> impl Iterator { - self.files.keys().map(|s| s.as_str()) - } -} - -// ── generate_project ───────────────────────────────────────────────────────── - -/// Generate a complete Pulsar class module tree from a [`ProjectSpec`]. -/// -/// Actor implementations live in `events/events.rs`, matching the layout used -/// by the Blueprint Editor and engine project builder: -/// -/// ```text -/// src/classes// -/// ├── mod.rs -/// ├── events/ -/// │ ├── mod.rs -/// │ └── events.rs -/// └── vars/ -/// └── mod.rs -/// ``` -pub fn generate_project(spec: &ProjectSpec) -> GeneratedProject { - let mut files = BTreeMap::new(); - files.insert("src/classes/mod.rs".into(), gen_blueprints_mod(spec)); - - for bp in &spec.blueprints { - let ident = to_snake_case(&bp.name); - files.insert( - format!("src/classes/{ident}/mod.rs"), - gen_blueprint_mod(&ident), - ); - files.insert( - format!("src/classes/{ident}/events/mod.rs"), - gen_blueprint_events_mod(), - ); - files.insert( - format!("src/classes/{ident}/events/events.rs"), - gen_blueprint_actor(bp), - ); - files.insert( - format!("src/classes/{ident}/vars/mod.rs"), - gen_blueprint_vars(bp), - ); - } - - GeneratedProject { files } -} - -/// Compile a graph and wrap it as a generated actor source file. -/// -/// This is the safest high-level API for callers that need runnable class code -/// (struct + `#[derive(EngineClass)]` + `impl Actor`) instead of raw logic. -pub fn compile_graph_to_actor_source( - blueprint_name: &str, - graph: &GraphDescription, -) -> Result { - let source = compile_graph(graph)?; - Ok(generate_blueprint_actor_source(blueprint_name, &source)) -} - -/// Wrap already compiled raw PBGC logic into a generated actor source file. -pub fn generate_blueprint_actor_source(blueprint_name: &str, compiled_source: &str) -> String { - let bp = CompiledBlueprint::new(blueprint_name, compiled_source); - gen_blueprint_actor(&bp) -} - -/// Wrap compiled logic + component data into a generated actor source file. -pub fn generate_blueprint_actor_source_with_components( - blueprint_name: &str, - compiled_source: &str, - components: Vec, -) -> String { - let bp = CompiledBlueprint::new(blueprint_name, compiled_source).with_components(components); - gen_blueprint_actor(&bp) -} - -// ── File generators ─────────────────────────────────────────────────────────── - -fn gen_blueprints_mod(spec: &ProjectSpec) -> String { - let mod_decls: String = spec - .blueprints - .iter() - .map(|bp| format!("pub mod {};\n", to_snake_case(&bp.name))) - .collect(); - - let use_decls: String = spec - .blueprints - .iter() - .map(|bp| { - let ident = to_snake_case(&bp.name); - let ty = to_pascal_case(&bp.name); - format!("pub use {ident}::{ty};\n") - }) - .collect(); - - format!( - r#"//! Blueprint class module — generated by PBGC. -//! -//! Blueprint class structs auto-register with `pulsar_reflection` via -//! `#[derive(EngineClass)]` in each generated file. - -{mod_decls} -{use_decls} -"# - ) -} - -fn gen_blueprint_mod(ident: &str) -> String { - format!( - r#"//! Blueprint class module: `{ident}` -//! Generated by PBGC. Do not hand-edit — changes will be overwritten. - -pub mod vars; -pub mod events; -pub use events::*; -"# - ) -} - -fn gen_blueprint_events_mod() -> String { - r#"//! Blueprint event module — generated by PBGC. - -pub mod events; -pub use events::*; -"# - .to_string() -} - -fn gen_blueprint_actor(bp: &CompiledBlueprint) -> String { - let ident = to_snake_case(&bp.name); - let ty = to_pascal_case(&bp.name); - - let enabled_components: Vec<&CompiledComponent> = - bp.components.iter().filter(|c| c.enabled).collect(); - let has_components = !enabled_components.is_empty(); - - // ── Set up custom events ────────────────────────────────────────────────── - let has_custom_events = - bp.source.contains("on_player_died") || bp.source.contains("emit_event"); - let event_helpers = if has_custom_events { - format!( - r#" -impl {ty} {{ - /// Initialise custom event subscriptions. - /// Called at the start of `begin_play`. - pub fn __init_events(&mut self) {{ - // Register custom event handlers (added by PBGC for custom blueprint events). - // Subscriptions are set up here so they are active before begin_play runs. - }} -}} -"# - ) - } else { - String::new() - }; - - // ── begin_play body ─────────────────────────────────────────────────────── - let mut begin_play_body = String::new(); - if has_components { - begin_play_body.push_str(" Self::__init_components(_entity, _world);\n"); - begin_play_body.push_str(" Self::__run_component_begin_plays(_entity, _world);\n"); - } - if has_custom_events { - begin_play_body.push_str(" self.__init_events();\n"); - } - if bp.has_begin_play { - begin_play_body.push_str(" logic::begin_play(_entity, _world);\n"); - } else { - begin_play_body.push_str(" // No begin_play event in this blueprint.\n"); - } - - // ── tick body ───────────────────────────────────────────────────────────── - let tick_body = if bp.has_tick { - " logic::tick(_entity, _world);\n".to_string() - } else { - " // No tick event in this blueprint.\n".to_string() - }; - - // ── Component helper impls ──────────────────────────────────────────────── - // Live-world hydration (#651): prefab defaults seed the actor's OWN scene - // entity, and only where the scene hasn't already provided the component. - let component_helpers = if has_components { - let mut init_body = String::new(); - let mut begin_plays_body = String::new(); - for comp in &enabled_components { - let class = &comp.class_name; - // Serialize the JSON defaults so they can be embedded as a string literal. - let json_str = serde_json::to_string(&comp.property_defaults) - .unwrap_or_else(|_| "{}".to_string()) - .replace('\\', "\\\\") - .replace('"', "\\\""); - init_body.push_str(&format!( - r#" if !pulsar_world_registry::world_component_present_for_class("{class}", world, entity) {{ - if let Err(__e) = pulsar_world_registry::hydrate_world_component_for_class( - "{class}", - world, - entity, - &serde_json::from_str::("{json_str}").unwrap_or_else(|_| serde_json::json!({{}})), - ) {{ - tracing::error!("blueprint `{ident}`: hydrating {class} failed: {{__e}}"); - }} - }} -"#, - class = class, - json_str = json_str, - ident = ident, - )); - begin_plays_body.push_str(&format!( - r#" if pulsar_reflection::REGISTRY.get_method("{class}", "begin_play").is_some() {{ - if let Err(__e) = pulsar_world_registry::dispatch::invoke_component_method( - world, - entity, - "{class}", - 0, - "begin_play", - vec![], - ) {{ - tracing::error!("blueprint `{ident}`: {class}::begin_play failed: {{__e}}"); - }} - }} -"#, - class = class, - ident = ident, - )); - } - format!( - r#" -impl {ty} {{ - /// Ensure every enabled prefab component exists on the actor's scene - /// entity in the LIVE world (#651). - /// - /// Idempotent AND scene-respecting: hydration fires only when the class - /// is absent, so per-instance values the scene already hydrated win over - /// the defaults baked in at compile time. Classes without a live World - /// registration are left to the JSON channel. Failures log and continue — - /// one bad component never blocks the actor. - pub fn __init_components(entity: Entity, world: &mut World) {{ -{init_body} }} - - /// Call `begin_play` on each declared component class that implements it, - /// through the same live-world dispatcher graph nodes use. - pub fn __run_component_begin_plays(entity: Entity, world: &mut World) {{ -{begin_plays_body} }} -}} -"# - ) - } else { - String::new() - }; - - // ── Logic source ────────────────────────────────────────────────────────── - let indented_source: String = logic_source_for_class(bp) - .lines() - .map(|line| { - if line.trim().is_empty() { - "\n".into() - } else { - format!(" {line}\n") - } - }) - .collect(); - - // events.rs lives at /events/events.rs and is declared as a sub-module - // of /events/mod.rs. The module path is therefore: - // crate::classes::::events::events (outer struct/impls) - // crate::classes::::events::events::logic (inner mod) - // - // vars/ lives at /vars/mod.rs and is declared by /mod.rs. - // Reaching it from the outer scope: super::super::vars - // Reaching it from inside mod logic: super::super::super::vars - // - // We do NOT emit `pub mod vars;` here — the parent mod.rs owns that declaration. - format!( - r#"//! Blueprint actor: `{ident}` -//! Generated by PBGC. Do not hand-edit — changes will be overwritten. -//! -//! Component access goes through `pulsar_world_registry`'s dispatcher -//! against the LIVE world each `Actor` callback receives (#651) — there is -//! no private component store. - -use pulsar_game::prelude::*; -use engine_class_derive::EngineClass; -#[allow(unused_imports)] -use pulsar_std::*; - -// vars lives two levels up: ::events::events → ::events → -#[allow(unused_imports)] -use super::super::vars::*; - -#[derive(EngineClass, Clone)] -pub struct {ty} {{}} - -impl {ty} {{ - pub fn new() -> Self {{ Self {{ }} }} -}} - -impl Default for {ty} {{ - fn default() -> Self {{ Self::new() }} -}} -{component_helpers} -{event_helpers} -impl Actor for {ty} {{ - fn begin_play(&mut self, _entity: Entity, _world: &mut World) {{ -{begin_play_body} }} - - // Signature MUST match the pinned `pulsar_scenedb::Actor` exactly. - // `Actor::tick` is deliberately TIME-FREE there (see that trait's doc): - // per-frame timing is the engine's concern and flows through ECS systems - // and blueprint dispatch, never through this callback. Do not add - // parameters here without changing SceneDB first — a mismatch is E0053 in - // every generated project. Guarded by pulsar_game's - // `blueprint_codegen_drift` compile probes (Pulsar-Native#652). - fn tick(&mut self, _entity: Entity, _world: &mut World) {{ -{tick_body} }} -}} - -mod logic {{ - // vars is three levels up inside this inline module: - // logic → events::events → events → → vars - #[allow(unused_imports)] - use super::super::super::vars::*; - #[allow(unused_imports)] - use pulsar_std::*; - -{indented_source}}} -"# - ) -} - -fn gen_blueprint_vars(bp: &CompiledBlueprint) -> String { - let body = extract_variable_storage_block(&bp.source) - .unwrap_or_else(|| default_variable_storage_block(&bp.variables)); - - format!( - "//! Blueprint class vars for `{}`\n//! Generated by PBGC. Do not hand-edit.\n\n{}\n", - to_snake_case(&bp.name), - body.trim() - ) -} - -fn logic_source_for_class(bp: &CompiledBlueprint) -> String { - if bp.variables.is_empty() { - return bp.source.clone(); - } - - strip_variable_storage_block(&bp.source) -} - -fn extract_variable_storage_block(source: &str) -> Option { - let start = source.find(VARIABLE_STORAGE_BEGIN)? + VARIABLE_STORAGE_BEGIN.len(); - let end = source.find(VARIABLE_STORAGE_END)?; - Some(source[start..end].trim().to_string()) -} - -fn strip_variable_storage_block(source: &str) -> String { - let Some(start) = source.find(VARIABLE_STORAGE_BEGIN) else { - return source.to_string(); - }; - let Some(end_marker_start) = source.find(VARIABLE_STORAGE_END) else { - return source.to_string(); - }; - let end = end_marker_start + VARIABLE_STORAGE_END.len(); - - let before = source[..start].trim_end(); - let after = source[end..].trim_start(); - - if before.is_empty() { - after.to_string() - } else if after.is_empty() { - before.to_string() - } else { - format!("{}\n\n{}", before, after) - } -} - -fn default_variable_storage_block(variables: &[CompiledVariable]) -> String { - if variables.is_empty() { - return "// This blueprint declares no class variables.".to_string(); - } - - let mut body = String::from("use std::cell::{Cell, RefCell};\n\nthread_local! {\n"); - let mut vars: Vec<_> = variables.iter().collect(); - vars.sort_by(|a, b| a.name.cmp(&b.name)); - - for var in vars { - let static_name = to_static_var_name(&var.name); - if is_copy_type(&var.rust_type) { - body.push_str(&format!( - " pub(super) static {}: Cell> = Cell::new(None);\n", - static_name, var.rust_type - )); - } else { - body.push_str(&format!( - " pub(super) static {}: RefCell> = RefCell::new(None);\n", - static_name, var.rust_type - )); - } - } - - body.push_str("}\n\n"); - body.push_str("#[inline]\npub(super) fn __pbgc_set_copy(slot: &Cell>, value: T) {\n slot.set(Some(value));\n}\n\n"); - body.push_str("#[inline]\npub(super) fn __pbgc_get_copy(slot: &Cell>, var_name: &str) -> T {\n slot.get().unwrap_or_else(|| panic!(\"PBGC variable '{}' read before assignment\", var_name))\n}\n\n"); - body.push_str("#[inline]\npub(super) fn __pbgc_set_clone(slot: &RefCell>, value: T) {\n *slot.borrow_mut() = Some(value);\n}\n\n"); - body.push_str("#[inline]\npub(super) fn __pbgc_get_clone(slot: &RefCell>, var_name: &str) -> T {\n slot.borrow().as_ref().cloned().unwrap_or_else(|| panic!(\"PBGC variable '{}' read before assignment\", var_name))\n}\n"); - body -} - -fn is_copy_type(type_str: &str) -> bool { - matches!( - type_str, - "i32" - | "i64" - | "u32" - | "u64" - | "f32" - | "f64" - | "bool" - | "char" - | "usize" - | "isize" - | "i8" - | "i16" - | "u8" - | "u16" - ) -} - -fn to_static_var_name(var_name: &str) -> String { - let mut out = String::from("PBGC_VAR_"); - for ch in var_name.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_uppercase()); - } else { - out.push('_'); - } - } - if out.ends_with('_') { - out.push('X'); - } - out -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_spec() -> ProjectSpec { - // Raw sources here stand in for ALREADY-COMPILED PBGC logic, which - // since #651 carries the live-world parameters on every event fn. - let source = "pub fn begin_play(_entity: pulsar_game::Entity, _world: &mut pulsar_game::World) { let x = add(1.0, 2.0); print_number(x); }"; - ProjectSpec::new("my_game") - .version("0.1.0") - .description("A test Pulsar game") - .add_blueprint(CompiledBlueprint::new("player_controller", source)) - .add_blueprint( - CompiledBlueprint::new( - "enemy_ai", - "pub fn tick(_entity: pulsar_game::Entity, _world: &mut pulsar_game::World) { }", - ) - .with_tick(true), - ) - } - - #[test] - fn generates_expected_files() { - let project = generate_project(&sample_spec()); - let paths: Vec<&str> = project.file_paths().collect(); - assert!(paths.contains(&"src/classes/mod.rs")); - assert!(paths.contains(&"src/classes/player_controller/mod.rs")); - assert!(paths.contains(&"src/classes/player_controller/events/mod.rs")); - assert!(paths.contains(&"src/classes/player_controller/events/events.rs")); - assert!(paths.contains(&"src/classes/player_controller/vars/mod.rs")); - assert!(paths.contains(&"src/classes/enemy_ai/mod.rs")); - assert!(paths.contains(&"src/classes/enemy_ai/events/mod.rs")); - assert!(paths.contains(&"src/classes/enemy_ai/events/events.rs")); - assert!(paths.contains(&"src/classes/enemy_ai/vars/mod.rs")); - } - - #[test] - fn actor_file_contains_struct_and_impl() { - let project = generate_project(&sample_spec()); - let actor = &project.files["src/classes/player_controller/events/events.rs"]; - assert!(actor.contains("pub struct PlayerController")); - assert!(actor.contains("#[derive(EngineClass, Clone)]")); - assert!(actor.contains("impl Actor for PlayerController")); - // #652: the emitted impl must match the pinned `pulsar_scenedb::Actor` - // trait exactly — `tick` is time-free there, and no emitted signature - // may mention either `GameTime` spelling or any crate that does not - // exist in the pinned graph. - assert!( - actor.contains("fn tick(&mut self, _entity: Entity, _world: &mut World)"), - "emitted tick signature drifted from the pinned Actor trait" - ); - assert!( - !actor.contains("GameTime"), - "Actor::tick must stay time-free" - ); - assert!( - !actor.contains("gamma_core"), - "emitted code may only reference crates present in the pinned graph" - ); - assert!(actor.contains("logic::begin_play(_entity, _world)")); - assert!(actor.contains("No tick event in this blueprint")); - // #651: the baked-store routing must never reappear. - assert!( - !actor.contains("__bp_with_comp") && !actor.contains("__bp_set_comp_ctx"), - "generated actors address the live world through the dispatcher, \ - not the retired thread-local ComponentStore context" - ); - } - - #[test] - fn generate_actor_source_includes_engineclass_derive() { - let actor = generate_blueprint_actor_source( - "player_controller", - "pub fn begin_play(_entity: pulsar_game::Entity, _world: &mut pulsar_game::World) {}\n", - ); - assert!(actor.contains("pub struct PlayerController")); - assert!(actor.contains("#[derive(EngineClass, Clone)]")); - } - - /// #651: prefab components hydrate onto the actor's LIVE scene entity - /// (absent-only, scene overrides win) instead of a private baked store. - #[test] - fn component_bearing_actors_hydrate_the_live_world() { - let source = - "pub fn begin_play(_entity: pulsar_game::Entity, _world: &mut pulsar_game::World) { }"; - let bp = CompiledBlueprint::new("light_probe", source) - .with_begin_play(true) - .with_components(vec![CompiledComponent { - class_name: "LightComponent".to_string(), - property_defaults: serde_json::json!({ "intensity": 1000.0 }), - enabled: true, - }]); - let spec = ProjectSpec::new("live_world_probes").add_blueprint(bp); - let project = generate_project(&spec); - let actor = &project.files["src/classes/light_probe/events/events.rs"]; - - assert!( - actor.contains("pub struct LightProbe {}"), - "no private component state may be emitted:\n{actor}" - ); - assert!(!actor.contains("ComponentStore")); - assert!(actor.contains("__init_components(entity: Entity, world: &mut World)")); - assert!( - actor.contains("world_component_present_for_class(\"LightComponent\", world, entity)"), - "hydration must be gated on live-world presence so scene-provided \ - per-instance values win:\n{actor}" - ); - assert!(actor.contains("hydrate_world_component_for_class(")); - assert!( - actor.contains("1000.0"), - "baked defaults must survive embedding:\n{actor}" - ); - assert!(actor.contains("Self::__init_components(_entity, _world);")); - } - - /// #651: disabled prefab components are skipped entirely — they hydrate - /// nowhere and receive no begin_play dispatch. - #[test] - fn disabled_prefab_components_are_not_emitted() { - let source = - "pub fn begin_play(_entity: pulsar_game::Entity, _world: &mut pulsar_game::World) { }"; - let bp = CompiledBlueprint::new("dark_probe", source).with_components(vec![ - CompiledComponent { - class_name: "LightComponent".to_string(), - property_defaults: serde_json::json!({}), - enabled: false, - }, - CompiledComponent { - class_name: "RigidbodyComponent".to_string(), - property_defaults: serde_json::json!({}), - enabled: true, - }, - ]); - let spec = ProjectSpec::new("enabled_probes").add_blueprint(bp); - let project = generate_project(&spec); - let actor = &project.files["src/classes/dark_probe/events/events.rs"]; - - assert!(!actor.contains("\"LightComponent\"")); - assert!(actor.contains("\"RigidbodyComponent\"")); - } - - #[test] - fn enemy_actor_wires_tick() { - let project = generate_project(&sample_spec()); - let actor = &project.files["src/classes/enemy_ai/events/events.rs"]; - // #651: tick logic receives the live-world slice from the Actor impl. - assert!(actor.contains("logic::tick(_entity, _world)")); - } - - #[test] - fn class_modules_own_vars_and_reexport_events() { - let project = generate_project(&sample_spec()); - let class_mod = &project.files["src/classes/player_controller/mod.rs"]; - let events_mod = &project.files["src/classes/player_controller/events/mod.rs"]; - - assert!(class_mod.contains("pub mod vars;")); - assert!(class_mod.contains("pub mod events;")); - assert!(class_mod.contains("pub use events::*;")); - assert!(events_mod.contains("pub mod events;")); - assert!(events_mod.contains("pub use events::*;")); - } - - #[test] - fn mod_file_exports_all_actors() { - let project = generate_project(&sample_spec()); - let modfile = &project.files["src/classes/mod.rs"]; - assert!(modfile.contains("pub mod player_controller")); - assert!(modfile.contains("pub mod enemy_ai")); - assert!(modfile.contains("pub use player_controller::PlayerController")); - assert!(modfile.contains("pub use enemy_ai::EnemyAi")); - assert!(!modfile.contains("fn compiled_class_names")); - assert!(!modfile.contains("fn spawn_compiled_class")); - } - - #[test] - fn snake_to_pascal() { - assert_eq!(to_pascal_case("player_controller"), "PlayerController"); - assert_eq!(to_pascal_case("enemy_ai"), "EnemyAi"); - } - - #[test] - fn pascal_to_snake() { - assert_eq!(to_snake_case("PlayerController"), "player_controller"); - assert_eq!(to_snake_case("my_cool_actor"), "my_cool_actor"); - } - - #[test] - fn write_to_dir() { - let project = generate_project(&sample_spec()); - let dir = std::env::temp_dir().join("pbgc_project_gen_test"); - project.write_to_dir(&dir).unwrap(); - assert!(dir.join("src/classes/mod.rs").exists()); - assert!(dir.join("src/classes/player_controller/mod.rs").exists()); - assert!(dir - .join("src/classes/player_controller/events/mod.rs") - .exists()); - assert!(dir - .join("src/classes/player_controller/events/events.rs") - .exists()); - assert!(dir - .join("src/classes/player_controller/vars/mod.rs") - .exists()); - assert!(dir.join("src/classes/enemy_ai/mod.rs").exists()); - assert!(dir.join("src/classes/enemy_ai/events/mod.rs").exists()); - assert!(dir.join("src/classes/enemy_ai/events/events.rs").exists()); - assert!(dir.join("src/classes/enemy_ai/vars/mod.rs").exists()); - } - - #[test] - fn variables_are_extracted_into_class_vars_module() { - let source = r#" -// PBGC_VARIABLE_STORAGE_BEGIN -thread_local! { - pub(super) static PBGC_VAR_HEALTH: std::cell::Cell> = std::cell::Cell::new(None); -} -// PBGC_VARIABLE_STORAGE_END - -pub fn begin_play(_entity: pulsar_game::Entity, _world: &mut pulsar_game::World) { } -"#; - - let bp = CompiledBlueprint::new("player", source).with_variables(vec![CompiledVariable { - name: "health".to_string(), - rust_type: "f64".to_string(), - default_value: Some("100.0".to_string()), - }]); - - let spec = ProjectSpec::new("game").add_blueprint(bp); - let project = generate_project(&spec); - - let class_mod = &project.files["src/classes/player/mod.rs"]; - let actor = &project.files["src/classes/player/events/events.rs"]; - let vars = &project.files["src/classes/player/vars/mod.rs"]; - - assert!(class_mod.contains("pub mod vars;")); - assert!(actor.contains("use super::super::vars::*;")); - assert!(!actor.contains("PBGC_VARIABLE_STORAGE_BEGIN")); - assert!(!actor.contains("thread_local!")); - assert!(vars.contains("thread_local!")); - assert!(vars.contains("PBGC_VAR_HEALTH")); - } -} diff --git a/src/vm/mod.rs b/src/vm/mod.rs deleted file mode 100644 index c9a6ab4..0000000 --- a/src/vm/mod.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::bytecode::{BpProgram, Instruction, LabelId, TypeSlot}; -use std::collections::HashMap; - -#[derive(Debug)] -pub enum VmError { - LabelNotFound(LabelId), - UnresolvedCall(String), - InsufficientArena { required: usize, provided: usize }, -} - -impl std::fmt::Display for VmError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VmError::LabelNotFound(l) => write!(f, "label {} not found", l), - VmError::UnresolvedCall(n) => { - write!(f, "unresolved call '{}' — forgot BpExecutor::prepare?", n) - } - VmError::InsufficientArena { required, provided } => { - write!( - f, - "insufficient arena size: required {}, provided {}", - required, provided - ) - } - } - } -} -impl std::error::Error for VmError {} - -/// ABI of every `__bp_dispatch_` symbol. -/// -/// `args` — array of pointers into the byte arena, one per input. -/// `ret` — pointer to the output region in the arena (null for void). -/// `type_slots` — array of `TypeSlot` values resolved at graph-compile time, -/// one per generic type parameter `T`. Concrete functions -/// receive this pointer but must not read it (pass null/empty). -pub type DispatchFn = - unsafe extern "C" fn(args: *const *const u8, ret: *mut u8, type_slots: *const TypeSlot); - -/// Execute a prepared `BpProgram`. -/// -/// All `fn_ptr` fields in `Instruction::Call` must have been patched by -/// `BpExecutor::prepare` before calling this. The loop resolves labels once -/// at startup, then executes purely via pointer arithmetic and direct calls. -pub fn run(program: &BpProgram) -> Result<(), VmError> { - // Allocate the byte arena. Vec guarantees 8-byte alignment of the base pointer. - let mut arena = vec![0u64; (program.arena_size + 7) / 8]; - let arena_ptr = arena.as_mut_ptr() as *mut u8; - - // SAFETY: arena_ptr comes from Vec, so it is 8-byte aligned and valid for - // program.arena_size bytes. - unsafe { run_with_external_arena(program, arena_ptr, program.arena_size) } -} - -/// Execute a prepared `BpProgram` with an external arena. -/// -/// This allows persistent variable state across multiple event executions. -/// The caller provides a mutable arena that will be used instead of allocating -/// a fresh one for each run. -/// -/// # Safety -/// - `arena` must be valid for reads/writes of at least `arena_size` bytes -/// - `arena` must be 8-byte aligned -/// - variable regions in the arena must be initialized before execution -pub unsafe fn run_with_external_arena( - program: &BpProgram, - arena: *mut u8, - arena_size: usize, -) -> Result<(), VmError> { - if arena_size < program.arena_size { - return Err(VmError::InsufficientArena { - required: program.arena_size, - provided: arena_size, - }); - } - - let base = arena; - - // Pre-allocate the argument-pointer scratch buffer once to avoid heap churn. - let mut arg_ptrs: Vec<*const u8> = Vec::with_capacity(program.max_args_count.max(1)); - // Scratch buffer: TypeSlot values collected contiguously for each generic Call. - // Generic dispatch functions receive `*const TypeSlot` into this buffer. - let mut type_slots_buf: Vec = Vec::new(); - - let labels = build_labels(&program.instructions); - let mut pc = 0usize; - - loop { - if pc >= program.instructions.len() { - break; - } - match &program.instructions[pc] { - Instruction::InitBytes { offset, bytes } => { - // SAFETY: offset + bytes.len() <= arena_size (guaranteed by codegen). - unsafe { - std::ptr::copy_nonoverlapping(bytes.as_ptr(), base.add(*offset), bytes.len()); - } - pc += 1; - } - - Instruction::InitTypeSlot { - offset, - size, - align, - } => { - // Write a TypeSlot { size, align } into the arena at *offset*. - // SAFETY: the codegen guarantees offset + sizeof(TypeSlot) <= arena_size - // and that the slot is 8-byte aligned. - unsafe { - let slot_ptr = base.add(*offset) as *mut TypeSlot; - std::ptr::write( - slot_ptr, - TypeSlot { - size: *size, - align: *align, - }, - ); - } - pc += 1; - } - - Instruction::Call { - fn_ptr, - node_type, - input_offsets, - output_offset, - has_output, - type_slot_offsets, - } => { - if *fn_ptr == 0 { - return Err(VmError::UnresolvedCall(node_type.clone())); - } - arg_ptrs.clear(); - for &off in input_offsets { - // SAFETY: offset is within the arena (guaranteed by codegen). - unsafe { - arg_ptrs.push(base.add(off)); - } - } - // Collect TypeSlot values into a contiguous scratch buffer so that - // generic dispatch functions can index them as type_slots[i]. - type_slots_buf.clear(); - for &off in type_slot_offsets { - unsafe { - type_slots_buf.push(*(base.add(off) as *const TypeSlot)); - } - } - unsafe { - let ret = if *has_output { - base.add(*output_offset) - } else { - std::ptr::null_mut() - }; - // Null when there are no type slots (concrete functions). - let ts_ptr = if type_slots_buf.is_empty() { - std::ptr::null() - } else { - type_slots_buf.as_ptr() - }; - // SAFETY: fn_ptr was resolved from the cdylib by the executor. - let f: DispatchFn = std::mem::transmute(*fn_ptr as usize); - f(arg_ptrs.as_ptr(), ret, ts_ptr); - } - pc += 1; - } - - Instruction::LoadVar { - source_offset, - output_offset, - size, - } => { - unsafe { - std::ptr::copy(base.add(*source_offset), base.add(*output_offset), *size); - } - pc += 1; - } - - Instruction::StoreVar { - input_offset, - target_offset, - size, - } => { - unsafe { - std::ptr::copy(base.add(*input_offset), base.add(*target_offset), *size); - } - pc += 1; - } - - Instruction::JumpIf { - condition_offset, - true_label, - false_label, - } => { - // bool is 1 byte; non-zero == true. - let cond = unsafe { *base.add(*condition_offset) != 0 }; - let target = if cond { *true_label } else { *false_label }; - pc = *labels.get(&target).ok_or(VmError::LabelNotFound(target))? + 1; - } - - Instruction::Jump(label) => { - pc = *labels.get(label).ok_or(VmError::LabelNotFound(*label))? + 1; - } - - Instruction::Label(_) => { - pc += 1; - } - - Instruction::Return => break, - } - } - Ok(()) -} - -fn build_labels(instructions: &[Instruction]) -> HashMap { - instructions - .iter() - .enumerate() - .filter_map(|(i, instr)| { - if let Instruction::Label(id) = instr { - Some((*id, i)) - } else { - None - } - }) - .collect() -} diff --git a/tests/bytecode_tests.rs b/tests/bytecode_tests.rs deleted file mode 100644 index 643db01..0000000 --- a/tests/bytecode_tests.rs +++ /dev/null @@ -1,1603 +0,0 @@ -/// PBGC bytecode compiler + VM tests. -/// -/// All dispatch is via raw `unsafe extern "C" fn(*const *const u8, *mut u8)` pointers — -/// the same ABI the `#[blueprint]` macro generates in pulsar_std. -/// No BpValue, no NodeDispatch trait, no type matching in the VM. -use std::time::Instant; - -use graphy::{ - Connection, ConnectionType, DataType, GraphDescription, NodeInstance, Pin, PinInstance, - PinType, Position, -}; -use pbgc::{ - compile_graph, compile_graph_to_bytecode, compile_graph_to_bytecode_with_variables, BpProgram, - Instruction, -}; -use std::collections::HashMap; - -// ── Native dispatch shims (mirrors what pulsar_macros #[blueprint] generates) ─ -// -// ABI: unsafe extern "C" fn(args: *const *const u8, ret: *mut u8) -// args[i] → pointer into the byte arena at the i-th input's offset -// ret → pointer to the output region in the arena - -unsafe extern "C" fn shim_add(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); - std::ptr::write(ret as *mut i64, a + b); -} -unsafe extern "C" fn shim_subtract(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); - std::ptr::write(ret as *mut i64, a - b); -} -unsafe extern "C" fn shim_multiply(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); - std::ptr::write(ret as *mut i64, a * b); -} -unsafe extern "C" fn shim_divide(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); - std::ptr::write(ret as *mut i64, if b == 0 { 0 } else { a / b }); -} -unsafe extern "C" fn shim_modulo(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const i64); - let b = std::ptr::read(*args.add(1) as *const i64); - std::ptr::write(ret as *mut i64, if b == 0 { 0 } else { a % b }); -} -unsafe extern "C" fn shim_greater_than(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const f64); - let b = std::ptr::read(*args.add(1) as *const f64); - std::ptr::write(ret as *mut bool, a > b); -} -unsafe extern "C" fn shim_less_than(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const f64); - let b = std::ptr::read(*args.add(1) as *const f64); - std::ptr::write(ret as *mut bool, a < b); -} -unsafe extern "C" fn shim_abs(args: *const *const u8, ret: *mut u8) { - let v = std::ptr::read(*args as *const f64); - std::ptr::write(ret as *mut f64, v.abs()); -} -unsafe extern "C" fn shim_sqrt(args: *const *const u8, ret: *mut u8) { - let v = std::ptr::read(*args as *const f64); - std::ptr::write(ret as *mut f64, v.sqrt()); -} -unsafe extern "C" fn shim_lerp(args: *const *const u8, ret: *mut u8) { - let a = std::ptr::read(*args as *const f64); - let b = std::ptr::read(*args.add(1) as *const f64); - let t = std::ptr::read(*args.add(2) as *const f64); - std::ptr::write(ret as *mut f64, a + (b - a) * t); -} -unsafe extern "C" fn shim_clamp(args: *const *const u8, ret: *mut u8) { - let v = std::ptr::read(*args as *const f64); - let min = std::ptr::read(*args.add(1) as *const f64); - let max = std::ptr::read(*args.add(2) as *const f64); - std::ptr::write(ret as *mut f64, v.clamp(min, max)); -} -unsafe extern "C" fn shim_print_string(_args: *const *const u8, _ret: *mut u8) {} - -// ── Assert shims ────────────────────────────────────────────────────────────── - -unsafe extern "C" fn shim_assert_true(args: *const *const u8, _ret: *mut u8) { - let cond = std::ptr::read(*args as *const bool); - assert!(cond, "assert_true failed: condition was false"); -} -unsafe extern "C" fn shim_assert_false(args: *const *const u8, _ret: *mut u8) { - let cond = std::ptr::read(*args as *const bool); - assert!(!cond, "assert_false failed: condition was true"); -} -unsafe extern "C" fn shim_assert_eq_int(args: *const *const u8, _ret: *mut u8) { - let actual = std::ptr::read(*args as *const i64); - let expected = std::ptr::read(*args.add(1) as *const i64); - assert_eq!(actual, expected, "assert_eq_int failed"); -} -unsafe extern "C" fn shim_assert_eq_float(args: *const *const u8, _ret: *mut u8) { - let actual = std::ptr::read(*args as *const f64); - let expected = std::ptr::read(*args.add(1) as *const f64); - let eps = std::ptr::read(*args.add(2) as *const f64); - assert!( - (actual - expected).abs() < eps, - "assert_eq_float failed: |{} - {}| = {} >= eps {}", - actual, - expected, - (actual - expected).abs(), - eps - ); -} - -// ── Dispatch table builder ──────────────────────────────────────────────────── - -fn prepare_with_shims(program: &mut BpProgram) { - for instr in &mut program.instructions { - if let Instruction::Call { - fn_ptr, node_type, .. - } = instr - { - *fn_ptr = match node_type.as_str() { - "add" => shim_add as u64, - "subtract" => shim_subtract as u64, - "multiply" => shim_multiply as u64, - "divide" => shim_divide as u64, - "modulo" => shim_modulo as u64, - "greater_than" => shim_greater_than as u64, - "less_than" => shim_less_than as u64, - "abs" => shim_abs as u64, - "sqrt" => shim_sqrt as u64, - "lerp" => shim_lerp as u64, - "clamp" => shim_clamp as u64, - "print_string" => shim_print_string as u64, - "assert_true" => shim_assert_true as u64, - "assert_false" => shim_assert_false as u64, - "assert_eq_int" => shim_assert_eq_int as u64, - "assert_eq_float" => shim_assert_eq_float as u64, - other => panic!("no shim for: {}", other), - }; - } - } -} - -fn run(program: &mut BpProgram) { - prepare_with_shims(program); - pbgc::vm::run(program).unwrap(); -} - -fn compile_and_run(graph: &GraphDescription) { - let mut programs = compile_graph_to_bytecode(graph).expect("compile failed"); - for prog in &mut programs { - run(prog); - } -} - -// ── Graph builders ──────────────────────────────────────────────────────────── - -fn begin(pin: &str) -> NodeInstance { - let mut n = NodeInstance::new("begin", "begin_play", Position { x: 0.0, y: 0.0 }); - n.outputs.push(PinInstance::new( - pin, - Pin::new(pin, "Body", DataType::Exec, PinType::Output), - )); - n -} - -fn add_node(id: &str, ca: Option, cb: Option) -> NodeInstance { - let mut n = NodeInstance::new(id, "add", Position { x: 100.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_a"), - Pin::new( - &format!("{id}_a"), - "a", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_b"), - Pin::new( - &format!("{id}_b"), - "b", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "result", - DataType::typed("i64"), - PinType::Output, - ), - )); - if let Some(v) = ca { - n.properties.insert(format!("{id}_a"), serde_json::json!(v)); - } - if let Some(v) = cb { - n.properties.insert(format!("{id}_b"), serde_json::json!(v)); - } - n -} - -fn get_bit_node(id: &str, value: i64, bit_index: i64) -> NodeInstance { - let mut n = NodeInstance::new(id, "get_bit", Position { x: 100.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_value"), - Pin::new( - &format!("{id}_value"), - "value", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_bit_index"), - Pin::new( - &format!("{id}_bit_index"), - "bit_index", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_result"), - Pin::new( - &format!("{id}_result"), - "result", - DataType::typed("i64"), - PinType::Output, - ), - )); - n.properties - .insert(format!("{id}_value"), serde_json::json!(value)); - n.properties - .insert(format!("{id}_bit_index"), serde_json::json!(bit_index)); - n -} - -fn mul_node(id: &str, ca: Option, cb: Option) -> NodeInstance { - let mut n = NodeInstance::new(id, "multiply", Position { x: 100.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_a"), - Pin::new( - &format!("{id}_a"), - "a", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_b"), - Pin::new( - &format!("{id}_b"), - "b", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "result", - DataType::typed("i64"), - PinType::Output, - ), - )); - if let Some(v) = ca { - n.properties.insert(format!("{id}_a"), serde_json::json!(v)); - } - if let Some(v) = cb { - n.properties.insert(format!("{id}_b"), serde_json::json!(v)); - } - n -} - -fn gt_node(id: &str, cb: Option) -> NodeInstance { - let mut n = NodeInstance::new(id, "greater_than", Position { x: 200.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_a"), - Pin::new( - &format!("{id}_a"), - "a", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_b"), - Pin::new( - &format!("{id}_b"), - "b", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "result", - DataType::typed("bool"), - PinType::Output, - ), - )); - if let Some(v) = cb { - n.properties.insert(format!("{id}_b"), serde_json::json!(v)); - } - n -} - -fn branch_node(id: &str) -> NodeInstance { - let mut n = NodeInstance::new(id, "branch", Position { x: 300.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_e"), - Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_c"), - Pin::new( - &format!("{id}_c"), - "condition", - DataType::typed("bool"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_t"), - Pin::new(&format!("{id}_t"), "True", DataType::Exec, PinType::Output), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_f"), - Pin::new(&format!("{id}_f"), "False", DataType::Exec, PinType::Output), - )); - n -} - -fn getter_node(id: &str, var_name: &str) -> NodeInstance { - let mut n = NodeInstance::new( - id, - &format!("get_{}", var_name), - Position { x: 100.0, y: 0.0 }, - ); - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "result", - DataType::typed("i64"), - PinType::Output, - ), - )); - n -} - -fn assert_eq_int_node(id: &str, expected: i64) -> NodeInstance { - let mut n = NodeInstance::new(id, "assert_eq_int", Position { x: 400.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_e"), - Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_a"), - Pin::new( - &format!("{id}_a"), - "actual", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_x"), - Pin::new( - &format!("{id}_x"), - "expected", - DataType::typed("i64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_o"), - Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), - )); - n.properties - .insert(format!("{id}_x"), serde_json::json!(expected as f64)); - n -} - -fn assert_eq_float_node(id: &str, expected: f64, epsilon: f64) -> NodeInstance { - let mut n = NodeInstance::new(id, "assert_eq_float", Position { x: 400.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_e"), - Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_a"), - Pin::new( - &format!("{id}_a"), - "actual", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_x"), - Pin::new( - &format!("{id}_x"), - "expected", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_ep"), - Pin::new( - &format!("{id}_ep"), - "epsilon", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_o"), - Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), - )); - n.properties - .insert(format!("{id}_x"), serde_json::json!(expected)); - n.properties - .insert(format!("{id}_ep"), serde_json::json!(epsilon)); - n -} - -fn assert_true_node(id: &str) -> NodeInstance { - let mut n = NodeInstance::new(id, "assert_true", Position { x: 400.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_e"), - Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_c"), - Pin::new( - &format!("{id}_c"), - "condition", - DataType::typed("bool"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_o"), - Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), - )); - n -} - -fn lerp_node(id: &str, ca: Option, cb: Option, ct: Option) -> NodeInstance { - let mut n = NodeInstance::new(id, "lerp", Position { x: 100.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_a"), - Pin::new( - &format!("{id}_a"), - "a", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_b"), - Pin::new( - &format!("{id}_b"), - "b", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_t"), - Pin::new( - &format!("{id}_t"), - "t", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "result", - DataType::typed("f64"), - PinType::Output, - ), - )); - if let Some(v) = ca { - n.properties.insert(format!("{id}_a"), serde_json::json!(v)); - } - if let Some(v) = cb { - n.properties.insert(format!("{id}_b"), serde_json::json!(v)); - } - if let Some(v) = ct { - n.properties.insert(format!("{id}_t"), serde_json::json!(v)); - } - n -} - -fn conn(from: &str, from_pin: &str, to: &str, to_pin: &str, ct: ConnectionType) -> Connection { - Connection::new(from, from_pin, to, to_pin, ct) -} -fn exec(from: &str, fp: &str, to: &str, tp: &str) -> Connection { - conn(from, fp, to, tp, ConnectionType::Execution) -} -fn data(from: &str, fp: &str, to: &str, tp: &str) -> Connection { - conn(from, fp, to, tp, ConnectionType::Data) -} - -// ── Instruction structure tests ─────────────────────────────────────────────── - -#[test] -fn test_init_bytes_emitted_for_integer_constant() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(add_node("a", Some(3.0), Some(4.0))); - g.add_node(assert_eq_int_node("chk", 7)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("a", "a_r", "chk", "chk_a")); - - let programs = compile_graph_to_bytecode(&g).unwrap(); - let has_init = programs[0] - .instructions - .iter() - .any(|i| matches!(i, Instruction::InitBytes { .. })); - assert!(has_init, "should emit InitBytes for integer constants"); -} - -#[test] -fn test_init_bytes_emitted_for_float_constant() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(lerp_node("l", Some(0.0), Some(1.0), Some(0.5))); - g.add_node(assert_eq_float_node("chk", 0.5, 1e-9)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("l", "l_r", "chk", "chk_a")); - - let programs = compile_graph_to_bytecode(&g).unwrap(); - let has_init = programs[0] - .instructions - .iter() - .any(|i| matches!(i, Instruction::InitBytes { .. })); - assert!(has_init, "should emit InitBytes for float constants"); -} - -#[test] -fn test_branch_emits_jumpif() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(gt_node("gt", Some(0.0))); - g.add_node(branch_node("br")); - g.add_connection(exec("begin", "be", "br", "br_e")); - g.add_connection(data("gt", "gt_r", "br", "br_c")); - - let programs = compile_graph_to_bytecode(&g).unwrap(); - assert!(programs[0] - .instructions - .iter() - .any(|i| matches!(i, Instruction::JumpIf { .. }))); -} - -#[test] -fn test_node_type_embedded_in_call_instruction() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(add_node("a", Some(1.0), Some(2.0))); - g.add_node(assert_eq_int_node("chk", 3)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("a", "a_r", "chk", "chk_a")); - - let programs = compile_graph_to_bytecode(&g).unwrap(); - let has_add = programs[0] - .instructions - .iter() - .any(|i| matches!(i, Instruction::Call { node_type, .. } if node_type == "add")); - assert!(has_add, "should have a Call with node_type='add'"); -} - -#[test] -fn standard_nodes_with_get_prefix_are_not_variable_getters() { - let mut g = GraphDescription::new("get_bit_is_standard_node"); - g.add_node(begin("be")); - g.add_node(get_bit_node("bit", 10, 1)); - g.add_node(assert_eq_int_node("chk", 1)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("bit", "bit_result", "chk", "chk_a")); - - let programs = compile_graph_to_bytecode(&g).expect("compile get_bit bytecode"); - assert!(programs.iter().any(|program| { - program.instructions.iter().any(|instruction| { - matches!(instruction, Instruction::Call { node_type, .. } if node_type == "get_bit") - }) - })); - - let rust = compile_graph(&g).expect("compile get_bit Rust"); - assert!(rust.contains("get_bit")); -} - -#[test] -fn test_arena_size_positive_when_nodes_produce_values() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(gt_node("gt", Some(0.0))); - let mut gt2 = gt_node("gt2", Some(0.0)); - g.add_node(gt2); - g.add_node(branch_node("br")); - g.add_connection(exec("begin", "be", "br", "br_e")); - g.add_connection(data("gt", "gt_r", "br", "br_c")); - let programs = compile_graph_to_bytecode(&g).unwrap(); - assert!(programs[0].arena_size > 0, "arena_size should be > 0"); -} - -// ── Correctness: arithmetic ─────────────────────────────────────────────────── - -#[test] -fn test_correct_add_3_4_eq_7() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(add_node("a", Some(3.0), Some(4.0))); - g.add_node(assert_eq_int_node("chk", 7)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("a", "a_r", "chk", "chk_a")); - compile_and_run(&g); -} - -#[test] -fn test_correct_multiply_6_7_eq_42() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(mul_node("m", Some(6.0), Some(7.0))); - g.add_node(assert_eq_int_node("chk", 42)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("m", "m_r", "chk", "chk_a")); - compile_and_run(&g); -} - -#[test] -fn test_correct_pythagorean_3_4_5() { - // 3²+4² = 25 - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(mul_node("s3", Some(3.0), Some(3.0))); - g.add_node(mul_node("s4", Some(4.0), Some(4.0))); - g.add_node(add_node("sum", None, None)); - g.add_node(assert_eq_int_node("chk", 25)); - g.add_connection(data("s3", "s3_r", "sum", "sum_a")); - g.add_connection(data("s4", "s4_r", "sum", "sum_b")); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("sum", "sum_r", "chk", "chk_a")); - compile_and_run(&g); -} - -#[test] -fn test_correct_add_chain_100_nodes_eq_100() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - for i in 0..100usize { - let id = format!("a{i}"); - let ca = if i == 0 { Some(0.0) } else { None }; - g.add_node(add_node(&id, ca, Some(1.0))); - if i > 0 { - let prev = format!("a{}", i - 1); - g.add_connection(data(&prev, &format!("{prev}_r"), &id, &format!("{id}_a"))); - } - } - g.add_node(assert_eq_int_node("chk", 100)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("a99", "a99_r", "chk", "chk_a")); - compile_and_run(&g); -} - -// ── Correctness: float math ─────────────────────────────────────────────────── - -#[test] -fn test_correct_lerp_midpoint() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(lerp_node("l", Some(0.0), Some(100.0), Some(0.5))); - g.add_node(assert_eq_float_node("chk", 50.0, 1e-9)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("l", "l_r", "chk", "chk_a")); - compile_and_run(&g); -} - -#[test] -fn test_correct_lerp_at_zero_is_a() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(lerp_node("l", Some(42.0), Some(100.0), Some(0.0))); - g.add_node(assert_eq_float_node("chk", 42.0, 1e-9)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("l", "l_r", "chk", "chk_a")); - compile_and_run(&g); -} - -#[test] -fn test_correct_lerp_at_one_is_b() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(lerp_node("l", Some(0.0), Some(77.0), Some(1.0))); - g.add_node(assert_eq_float_node("chk", 77.0, 1e-9)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("l", "l_r", "chk", "chk_a")); - compile_and_run(&g); -} - -// ── Correctness: control flow ───────────────────────────────────────────────── - -#[test] -fn test_correct_branch_true_fires_assert_true() { - // 10 > 5 → true → assert_true - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - let mut gt = gt_node("gt", Some(5.0)); - gt.properties - .insert("gt_a".to_string(), serde_json::json!(10.0)); - g.add_node(gt); - g.add_node(branch_node("br")); - g.add_node(assert_true_node("at")); - g.add_connection(exec("begin", "be", "br", "br_e")); - g.add_connection(data("gt", "gt_r", "br", "br_c")); - g.add_connection(exec("br", "br_t", "at", "at_e")); - g.add_connection(data("gt", "gt_r", "at", "at_c")); - compile_and_run(&g); -} - -#[test] -fn test_correct_branch_false_path_not_taken_for_true_condition() { - // 10 > 0 is true — false branch should NOT execute. - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - let mut gt = gt_node("gt", Some(0.0)); - gt.properties - .insert("gt_a".to_string(), serde_json::json!(10.0)); - g.add_node(gt); - g.add_node(branch_node("br")); - g.add_node(assert_true_node("at")); - g.add_node(assert_true_node("canary")); // would fail if reached (condition defaults to 0/false) - g.add_connection(exec("begin", "be", "br", "br_e")); - g.add_connection(data("gt", "gt_r", "br", "br_c")); - g.add_connection(exec("br", "br_t", "at", "at_e")); - g.add_connection(data("gt", "gt_r", "at", "at_c")); - g.add_connection(exec("br", "br_f", "canary", "canary_e")); - compile_and_run(&g); -} - -// ── Bytecode serialisation ──────────────────────────────────────────────────── - -#[test] -fn test_serde_roundtrip_preserves_instructions() { - let mut g = GraphDescription::new("t"); - g.add_node(begin("be")); - g.add_node(add_node("a", Some(5.0), Some(3.0))); - g.add_node(assert_eq_int_node("chk", 8)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("a", "a_r", "chk", "chk_a")); - - let programs = compile_graph_to_bytecode(&g).unwrap(); - let json = serde_json::to_string(&programs[0]).unwrap(); - let mut restored: BpProgram = serde_json::from_str(&json).unwrap(); - assert_eq!(programs[0].instructions.len(), restored.instructions.len()); - assert_eq!(programs[0].arena_size, restored.arena_size); - // fn_ptrs are 0 after deserialization — re-prepare before running - run(&mut restored); -} - -// ── Timing ─────────────────────────────────────────────────────────────────── - -fn deep_pure_graph(n: usize) -> GraphDescription { - let mut g = GraphDescription::new("deep"); - g.add_node(begin("be")); - for i in 0..n { - let id = format!("a{i}"); - let ca = if i == 0 { Some(0.0) } else { None }; - g.add_node(add_node(&id, ca, Some(1.0))); - if i > 0 { - let prev = format!("a{}", i - 1); - g.add_connection(data(&prev, &format!("{prev}_r"), &id, &format!("{id}_a"))); - } - } - let last = format!("a{}", n - 1); - g.add_node(gt_node("gt", Some(0.0))); - g.add_node(branch_node("br")); - g.add_connection(data(&last, &format!("{last}_r"), "gt", "gt_a")); - g.add_connection(exec("begin", "be", "br", "br_e")); - g.add_connection(data("gt", "gt_r", "br", "br_c")); - g -} - -#[test] -fn deep_pure_chain_compiles_without_stack_growth() { - let programs = compile_graph_to_bytecode(&deep_pure_graph(500_000)) - .expect("deep pure graph should compile without stack overflow"); - assert_eq!(programs.len(), 1); - assert!(programs[0].instructions.len() >= 500_000); -} - -#[test] -fn test_timing_compile_10_nodes() { - let g = deep_pure_graph(10); - let t = Instant::now(); - let p = compile_graph_to_bytecode(&g).unwrap(); - println!("[timing] 10-node compile: {:?}", t.elapsed()); - assert!(!p.is_empty()); -} - -#[test] -fn test_timing_compile_50_nodes_vs_rust_codegen() { - let g = deep_pure_graph(50); - let t0 = Instant::now(); - let _ = compile_graph(&g).unwrap(); - let rust_t = t0.elapsed(); - let t1 = Instant::now(); - let _ = compile_graph_to_bytecode(&g).unwrap(); - let bc_t = t1.elapsed(); - println!("[timing] 50-node Rust={:?} Bytecode={:?}", rust_t, bc_t); -} - -#[test] -fn test_timing_vm_execute_1000_times() { - let g = deep_pure_graph(10); - let mut programs = compile_graph_to_bytecode(&g).unwrap(); - prepare_with_shims(&mut programs[0]); - let t = Instant::now(); - for _ in 0..1_000 { - pbgc::vm::run(&programs[0]).unwrap(); - } - println!( - "[timing] 1000 × 10-node VM: {:?} ({:.2}µs/run)", - t.elapsed(), - t.elapsed().as_micros() as f64 / 1000.0 - ); -} - -// ── Cycle detection ────────────────────────────────────────────────────────── - -#[test] -fn pure_dependency_cycle_detected() { - use graphy::GraphyError; - let mut g = GraphDescription::new("cycle"); - g.add_node(begin("be")); - // A -> B -> C -> A pure cycle - g.add_node(add_node("a", Some(1.0), None)); - g.add_node(add_node("b", None, None)); - g.add_node(add_node("c", None, None)); - g.add_connection(data("a", "a_r", "b", "b_a")); - g.add_connection(data("b", "b_r", "c", "c_a")); - g.add_connection(data("c", "c_r", "a", "a_b")); - // Trigger compilation by connecting a pure node to an exec path - g.add_node(assert_eq_int_node("chk", 0)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - g.add_connection(data("a", "a_r", "chk", "chk_a")); - - let result = compile_graph_to_bytecode(&g); - assert!(result.is_err(), "expected cyclic dependency error"); - let err = result.unwrap_err(); - assert!( - matches!(err, GraphyError::CyclicDependency) - || matches!(&err, GraphyError::Custom(msg) if msg.contains("Cyclic dependency")), - "expected cycle error, got {:?}", - err - ); -} - -// ── Getter deduplication ──────────────────────────────────────────────────── - -#[test] -fn getter_with_multiple_consumers_emits_once() { - let mut vars = HashMap::new(); - vars.insert("myvar".to_string(), "i64".to_string()); - let mut g = GraphDescription::new("getter_dedup"); - g.add_node(begin("be")); - // Create a get_myvar node - g.add_node(getter_node("g", "myvar")); - // Two pure nodes that both read from get_myvar - g.add_node(add_node("a", None, None)); - g.add_node(add_node("b", None, None)); - g.add_node(assert_eq_int_node("chk", 0)); - g.add_connection(exec("begin", "be", "chk", "chk_e")); - // Connect getter to both pure nodes - g.add_connection(data("g", "g_r", "a", "a_a")); - g.add_connection(data("g", "g_r", "b", "b_a")); - // Connect both pure nodes through chained add to assert - g.add_connection(data("a", "a_r", "chk", "chk_a")); - - let programs = - compile_graph_to_bytecode_with_variables(&g, vars).expect("compile with variables"); - let loadvar_count = programs[0] - .instructions - .iter() - .filter(|i| matches!(i, Instruction::LoadVar { .. })) - .count(); - assert_eq!( - loadvar_count, 1, - "expected exactly one LoadVar for getter shared by multiple consumers, got {}", - loadvar_count - ); -} - -// ── Component ops (comp_get_prop / comp_set_prop / comp_call) ──────────────── - -use pbgc::bytecode::comp_ops::{decode_name_blob, json_blob_len, CompOpKind, ComponentOpRef}; -use pbgc::bytecode::TypeSlot; -use pbgc::compile_graph_to_bytecode_full; -use std::cell::RefCell; - -fn comp_get_node(id: &str, class: &str, prop: &str) -> NodeInstance { - let mut n = NodeInstance::new( - id, - &format!("comp_get_prop::{class}::{prop}"), - Position { x: 100.0, y: 0.0 }, - ); - // Editor comp_* nodes always carry the (optional) component_ref target - // pin (#654); unconnected means self-targeted. - n.inputs.push(PinInstance::new( - "component_ref", - Pin::new( - "component_ref", - "component", - DataType::typed("ComponentRef"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "value", - DataType::typed("f64"), - PinType::Output, - ), - )); - n -} - -fn comp_set_node(id: &str, class: &str, prop: &str, value: Option) -> NodeInstance { - let mut n = NodeInstance::new( - id, - &format!("comp_set_prop::{class}::{prop}"), - Position { x: 200.0, y: 0.0 }, - ); - n.inputs.push(PinInstance::new( - &format!("{id}_e"), - Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), - )); - n.inputs.push(PinInstance::new( - "component_ref", - Pin::new( - "component_ref", - "component", - DataType::typed("ComponentRef"), - PinType::Input, - ), - )); - n.inputs.push(PinInstance::new( - &format!("{id}_v"), - Pin::new( - &format!("{id}_v"), - "value", - DataType::typed("f64"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_o"), - Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), - )); - if let Some(v) = value { - n.properties.insert(format!("{id}_v"), serde_json::json!(v)); - } - n -} - -fn comp_call_node(id: &str, class: &str, method: &str, with_return: bool) -> NodeInstance { - let mut n = NodeInstance::new( - id, - &format!("comp_call::{class}::{method}"), - Position { x: 300.0, y: 0.0 }, - ); - n.inputs.push(PinInstance::new( - &format!("{id}_e"), - Pin::new(&format!("{id}_e"), "exec", DataType::Exec, PinType::Input), - )); - n.inputs.push(PinInstance::new( - "component_ref", - Pin::new( - "component_ref", - "component", - DataType::typed("ComponentRef"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_o"), - Pin::new(&format!("{id}_o"), "exec", DataType::Exec, PinType::Output), - )); - if with_return { - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "result", - DataType::typed("f64"), - PinType::Output, - ), - )); - } - n -} - -#[test] -fn comp_ops_compile_with_staged_operands_and_component_list() { - let mut g = GraphDescription::new("comp_ops"); - g.add_node(begin("be")); - g.add_node(comp_set_node("set", "Light", "intensity", Some(3.5))); - g.add_connection(exec("begin", "be", "set", "set_e")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - assert_eq!( - compiled.components, - vec![ComponentOpRef { - kind: CompOpKind::SetProp, - class_name: "Light".into(), - member: "intensity".into(), - }] - ); - - // Name blob + JSON value blob are staged as InitBytes; the op is a Call - // carrying the full node_type so the executor can route on the prefix. - let prog = &compiled.programs[0]; - assert!(prog.instructions.iter().any(|i| matches!( - i, - Instruction::Call { node_type, input_offsets, has_output: false, .. } - if node_type == "comp_set_prop::Light::intensity" && input_offsets.len() == 2 - ))); - - // Serde round trip preserves everything byte-for-byte. - let json = serde_json::to_string(prog).unwrap(); - let back: pbgc::BpProgram = serde_json::from_str(&json).unwrap(); - assert_eq!(back.instructions.len(), prog.instructions.len()); -} - -#[test] -fn comp_get_feeding_comp_set_compiles_as_blob_chain() { - let mut g = GraphDescription::new("comp_chain"); - g.add_node(begin("be")); - g.add_node(comp_get_node("get", "Light", "intensity")); - g.add_node(comp_set_node("set", "Light", "intensity", None)); - g.add_connection(exec("begin", "be", "set", "set_e")); - g.add_connection(data("get", "get_r", "set", "set_v")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - assert_eq!(compiled.components.len(), 2); - let prog = &compiled.programs[0]; - // The set's value input resolves to the get's reserved output slot. - let get_out = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { - node_type, - output_offset, - has_output: true, - .. - } if node_type == "comp_get_prop::Light::intensity" => Some(*output_offset), - _ => None, - }); - let set_in = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { - node_type, - input_offsets, - .. - } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets[1]), - _ => None, - }); - assert_eq!(get_out, set_in, "get output slot feeds set value input"); -} - -#[test] -fn comp_call_without_return_has_no_output_slot() { - let mut g = GraphDescription::new("comp_call_void"); - g.add_node(begin("be")); - g.add_node(comp_call_node("call", "Door", "open", false)); - g.add_connection(exec("begin", "be", "call", "call_e")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - let has_output = compiled.programs[0].instructions.iter().any(|i| { - matches!( - i, - Instruction::Call { node_type, has_output: true, .. } - if node_type == "comp_call::Door::open" - ) - }); - assert!(!has_output, "void call must not reserve an output slot"); -} - -// Stub component-op handlers mirroring what the world-connected executor -// (#647) will do: parse the name blob, decode JSON values, act. - -thread_local! { - static COMP_OPS_LOG: RefCell> = const { RefCell::new(Vec::new()) }; -} - -/// Read a `{class}\0{member}\0` name blob staged in the arena: scan past two -/// NUL terminators and hand back exactly that span. -unsafe fn arena_name_blob<'a>(mut ptr: *const u8) -> &'a [u8] { - let start = ptr; - let mut terminators = 0usize; - while terminators < 2 { - if *ptr == 0 { - terminators += 1; - } - ptr = ptr.add(1); - } - std::slice::from_raw_parts(start, ptr.offset_from(start) as usize) -} - -unsafe fn log_op(kind: &str, name_ptr: *const u8, value_ptr: Option<*const u8>) { - let (class, member) = decode_name_blob(arena_name_blob(name_ptr)).unwrap(); - let value = value_ptr.map(|p| { - let len = json_blob_len(p); - let bytes = std::slice::from_raw_parts(p.add(8), len); - String::from_utf8_lossy(bytes).into_owned() - }); - COMP_OPS_LOG.with(|log| { - log.borrow_mut().push(format!( - "{kind}:{class}:{member}:{}", - value.unwrap_or_default() - )) - }); -} - -unsafe extern "C" fn stub_comp_get(args: *const *const u8, ret: *mut u8, _ts: *const TypeSlot) { - log_op("get", *args, None); - pbgc::bytecode::comp_ops::write_json_blob(ret, "42.5"); -} - -unsafe extern "C" fn stub_comp_set(args: *const *const u8, _ret: *mut u8, _ts: *const TypeSlot) { - log_op("set", *args, Some(*args.add(1))); -} - -unsafe extern "C" fn stub_comp_call(args: *const *const u8, ret: *mut u8, _ts: *const TypeSlot) { - log_op("call", *args, None); - if !ret.is_null() { - pbgc::bytecode::comp_ops::write_json_blob(ret, "1.0"); - } -} - -fn prepare_comp_shims(program: &mut BpProgram) { - for instr in &mut program.instructions { - if let Instruction::Call { - fn_ptr, node_type, .. - } = instr - { - if node_type.starts_with("comp_get_prop::") { - *fn_ptr = stub_comp_get as u64; - } else if node_type.starts_with("comp_set_prop::") { - *fn_ptr = stub_comp_set as u64; - } else if node_type.starts_with("comp_call::") { - *fn_ptr = stub_comp_call as u64; - } - } - } -} - -#[test] -fn comp_ops_execute_against_stub_handlers() { - COMP_OPS_LOG.with(|log| log.borrow_mut().clear()); - - let mut g = GraphDescription::new("comp_exec"); - g.add_node(begin("be")); - g.add_node(comp_set_node("set", "Light", "intensity", Some(7.0))); - g.add_node(comp_call_node("call", "Door", "open", true)); - g.add_connection(exec("begin", "be", "set", "set_e")); - g.add_connection(exec("set", "set_o", "call", "call_e")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - for mut prog in compiled.programs { - prepare_comp_shims(&mut prog); - pbgc::vm::run(&prog).unwrap(); - } - - let log = COMP_OPS_LOG.with(|log| log.borrow().clone()); - assert_eq!(log.len(), 2, "both component ops executed"); - assert_eq!(log[1], "call:Door:open:"); - assert!(log[0].starts_with("set:Light:intensity:"), "{}", log[0]); - // The staged constant survives as JSON; number formatting may differ. - let staged: f64 = log[0]["set:Light:intensity:".len()..] - .parse() - .unwrap_or_else(|_| panic!("staged value is not a JSON number: {}", log[0])); - assert!((staged - 7.0).abs() < 1e-6); -} - -#[test] -fn comp_get_result_flows_into_set_through_arena() { - COMP_OPS_LOG.with(|log| log.borrow_mut().clear()); - - let mut g = GraphDescription::new("comp_flow"); - g.add_node(begin("be")); - g.add_node(comp_get_node("get", "Light", "intensity")); - g.add_node(comp_set_node("set", "Light", "intensity", None)); - g.add_connection(exec("begin", "be", "set", "set_e")); - g.add_connection(data("get", "get_r", "set", "set_v")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - for mut prog in compiled.programs { - prepare_comp_shims(&mut prog); - pbgc::vm::run(&prog).unwrap(); - } - - let log = COMP_OPS_LOG.with(|log| log.borrow().clone()); - assert_eq!( - log, - vec![ - "get:Light:intensity:".to_string(), - "set:Light:intensity:42.5".to_string(), - ] - ); -} - -// ── Rust-source emission (#651) ─────────────────────────────────────────────── -// -// The same graphs also compile to generated actor source. That emission must -// route through `pulsar_world_registry`'s dispatcher against the live-world -// `(entity, world)` parameters and never re-introduce the retired -// baked-store routing (`__bp_with_comp` + private `ComponentStore`). - -#[test] -fn rust_emission_routes_comp_ops_through_the_live_dispatcher() { - let mut g = GraphDescription::new("rust_comp"); - g.add_node(begin("be")); - g.add_node(comp_get_node("get", "Light", "intensity")); - g.add_node(comp_set_node("set", "Light", "intensity", Some(7.0))); - g.add_node(comp_call_node("call", "Door", "open", true)); - g.add_connection(exec("begin", "be", "set", "set_e")); - g.add_connection(data("get", "get_r", "set", "set_v")); - g.add_connection(exec("set", "set_o", "call", "call_e")); - - let logic = pbgc::compile_graph(&g).expect("rust compilation"); - let actor = pbgc::generate_blueprint_actor_source_with_components( - "rust_probe", - &logic, - vec![pbgc::CompiledComponent { - class_name: "Light".to_string(), - property_defaults: serde_json::json!({ "intensity": 1.0 }), - enabled: true, - }], - ); - - // Dispatcher calls addressed at the live-world parameters. - assert!( - actor.contains("pulsar_world_registry::dispatch::get_component_property("), - "comp_get_prop must read through the live dispatcher:\n{actor}" - ); - assert!( - actor.contains("pulsar_world_registry::dispatch::set_component_property(\n"), - "comp_set_prop must write through the live dispatcher:\n{actor}" - ); - assert!( - actor.contains("json_args_to_method_args(\"Door\", \"open\"") - && actor.contains( - "invoke_component_method(_world, __bp_target_entity, \"Door\", __bp_target_index, \"open\", __args)" - ), - "comp_call must dispatch through the shared JSON->typed conversion:\n{actor}" - ); - // Generated event functions receive the live world from the Actor impl. - assert!(actor.contains( - "pub fn begin_play(_entity: pulsar_game::Entity, _world: &mut pulsar_game::World)" - )); - // Live-world hydration of prefab components. - assert!(actor.contains("world_component_present_for_class(\"Light\"")); - assert!(actor.contains("hydrate_world_component_for_class(")); - - // The retired baked-store routing must be gone outright (#651). - assert!(!actor.contains("__bp_with_comp")); - assert!(!actor.contains("__bp_set_comp_ctx")); - assert!(!actor.contains("__bp_clear_comp_ctx")); - assert!(!actor.contains("ComponentStore")); - assert!(!actor.contains("gamma_core")); -} - -// ── Identity references (#654) ──────────────────────────────────────────────── - -use pbgc::bytecode::comp_ops::{decode_targeted_call_name_blob, decode_targeted_name_blob}; - -/// A `get_component_ref::Class::N` node: ComponentRef-typed output, optional -/// `actor` redirect input. -fn get_ref_node(id: &str, class: &str, index: u32, with_actor_pin: bool) -> NodeInstance { - let mut n = NodeInstance::new( - id, - &format!("get_component_ref::{class}::{index}"), - Position { x: 50.0, y: 0.0 }, - ); - if with_actor_pin { - n.inputs.push(PinInstance::new( - "actor", - Pin::new( - "actor", - "actor", - DataType::typed("ActorRef"), - PinType::Input, - ), - )); - } - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "component", - DataType::typed("ComponentRef"), - PinType::Output, - ), - )); - n -} - -/// A `find_object_by_name` resolver node: String needle in, ActorRef out. -fn find_node(id: &str, needle: &str) -> NodeInstance { - let mut n = NodeInstance::new(id, "find_object_by_name", Position { x: 25.0, y: 0.0 }); - n.inputs.push(PinInstance::new( - &format!("{id}_n"), - Pin::new( - &format!("{id}_n"), - "name", - DataType::typed("String"), - PinType::Input, - ), - )); - n.outputs.push(PinInstance::new( - &format!("{id}_r"), - Pin::new( - &format!("{id}_r"), - "actor", - DataType::typed("ActorRef"), - PinType::Output, - ), - )); - n.properties - .insert(format!("{id}_n"), serde_json::json!(needle)); - n -} - -/// #654: a comp_set_prop with a CONNECTED component_ref pin stages the -/// targeted name blob plus the reference operand between name and value. -#[test] -fn pin_targeted_comp_set_stages_reference_operand() { - let mut g = GraphDescription::new("pin_targeted"); - g.add_node(begin("be")); - g.add_node(get_ref_node("ref", "Light", 0, false)); - g.add_node(comp_set_node("set", "Light", "intensity", Some(3.0))); - g.add_connection(data("ref", "ref_r", "set", "component_ref")); - g.add_connection(exec("begin", "be", "set", "set_e")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - let prog = &compiled.programs[0]; - let call = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { - node_type, - input_offsets, - .. - } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets.clone()), - _ => None, - }); - let input_offsets = call.expect("pin-targeted set must compile to a Call"); - // name + reference operand + value operand. - assert_eq!(input_offsets.len(), 3); - - // The staged name blob carries the trailing `pin` target field. - let blob = prog.instructions.iter().find_map(|i| match i { - Instruction::InitBytes { offset, bytes } if *offset == input_offsets[0] => { - Some(bytes.clone()) - } - _ => None, - }); - let fields = decode_targeted_name_blob(&blob.expect("name blob staged")).expect("decodes"); - assert_eq!(fields.target, pbgc::bytecode::comp_ops::RefTarget::RefPin); - assert_eq!( - compiled.components, - vec![ - ComponentOpRef { - kind: CompOpKind::GetRef, - class_name: "Light".into(), - member: "0".into() - }, - ComponentOpRef { - kind: CompOpKind::SetProp, - class_name: "Light".into(), - member: "intensity".into() - }, - ] - ); -} - -/// #654: find_object_by_name feeding get_component_ref's actor pin, feeding -/// a comp_get_prop's component_ref pin — the whole cross-object chain -/// compiles as pure blob producers with runtime resolution. -#[test] -fn cross_object_chain_compiles_as_identity_producers() { - let mut g = GraphDescription::new("cross_object"); - g.add_node(begin("be")); - g.add_node(find_node("find", "door_light")); - g.add_node(get_ref_node("ref", "Light", 2, true)); - g.add_node(comp_get_node("get", "Light", "intensity")); - g.add_connection(data("find", "find_r", "ref", "actor")); - g.add_connection(data("ref", "ref_r", "get", "component_ref")); - g.add_node(comp_set_node("set", "Light", "intensity", None)); - g.add_connection(exec("begin", "be", "set", "set_e")); - g.add_connection(data("get", "get_r", "set", "set_v")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - let prog = &compiled.programs[0]; - - // All three identity/value producers emitted Calls with outputs. - for node_type in [ - "find_object_by_name", - "get_component_ref::Light::2", - "comp_get_prop::Light::intensity", - "comp_set_prop::Light::intensity", - ] { - assert!( - prog.instructions.iter().any(|i| matches!( - i, - Instruction::Call { node_type: nt, .. } if nt == node_type - )), - "missing Call for {node_type}" - ); - } - - // The get op is pin-targeted (ref wired into its component_ref pin): - // name + reference operand. - let get_inputs = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { - node_type, - input_offsets, - .. - } if node_type == "comp_get_prop::Light::intensity" => Some(input_offsets.len()), - _ => None, - }); - assert_eq!(get_inputs, Some(2)); - // The set stays self-targeted (its own component_ref pin unconnected): - // legacy name + value shape. - let set_inputs = prog.instructions.iter().find_map(|i| match i { - Instruction::Call { - node_type, - input_offsets, - .. - } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets.len()), - _ => None, - }); - assert_eq!(set_inputs, Some(2)); -} - -/// #654: the Rust emission routes cross-object graphs through -/// `pulsar_game::script_refs` — the SAME helpers the VM trampolines call — -/// with the dispatcher still doing the property access. -#[test] -fn rust_emission_routes_references_through_script_refs() { - let mut g = GraphDescription::new("rust_refs"); - g.add_node(begin("be")); - g.add_node(find_node("find", "door_light")); - g.add_node(get_ref_node("ref", "Light", 0, true)); - g.add_connection(data("find", "find_r", "ref", "actor")); - g.add_node(comp_set_node("set", "Light", "color", None)); - g.add_connection(data("ref", "ref_r", "set", "component_ref")); - g.add_connection(exec("begin", "be", "set", "set_e")); - g.add_node(comp_get_node("get", "Light", "color")); - g.add_connection(data("ref", "ref_r", "get", "component_ref")); - - let logic = pbgc::compile_graph(&g).expect("rust compilation"); - assert!( - logic.contains("pulsar_game::script_refs::find_object_by_name("), - "resolver nodes must resolve through script_refs:\n{logic}" - ); - assert!( - logic.contains("pulsar_game::script_refs::component_ref_json(") - && logic.contains("\"Light\",\n 0,"), - "get_component_ref must build its ref through script_refs:\n{logic}" - ); - assert!( - logic.contains("pulsar_game::script_refs::resolve_pin_target("), - "pin-targeted ops must resolve their target through script_refs:\n{logic}" - ); - assert!( - logic.contains("__bp_target_index,"), - "pin-targeted ops must carry the reference's component_index:\n{logic}" - ); - - // An object literal emits its save/load form for runtime resolution — - // never baked entity bits (#639). It only materializes when consumed, - // so wire it into a set op's component_ref pin. - let mut lit = GraphDescription::new("rust_literal"); - lit.add_node(begin("be")); - let mut literal = NodeInstance::new("lit", "object_ref_literal", Position { x: 10.0, y: 0.0 }); - literal.outputs.push(PinInstance::new( - "lit_r", - Pin::new( - "lit_r", - "component", - DataType::typed("ComponentRef"), - PinType::Output, - ), - )); - literal - .properties - .insert("stable_id".to_string(), serde_json::json!("door")); - literal - .properties - .insert("class_name".to_string(), serde_json::json!("Light")); - literal - .properties - .insert("component_index".to_string(), serde_json::json!(1)); - lit.add_node(literal); - lit.add_node(comp_set_node("set", "Light", "color", Some(9.0))); - lit.add_connection(data("lit", "lit_r", "set", "component_ref")); - lit.add_connection(exec("begin", "be", "set", "set_e")); - let lit_logic = pbgc::compile_graph(&lit).expect("literal compilation"); - assert!( - lit_logic.contains("pulsar_game::script_refs::object_literal_json(") - && lit_logic.contains("\"door\",") - && lit_logic.contains("\"Light\","), - "literals must emit their serialized form:\n{lit_logic}" - ); - assert!( - lit_logic.contains("resolve_pin_target("), - "the consuming set must resolve through the literal:\n{lit_logic}" - ); -} - -/// #654: self-targeted ops carry the EXPLICIT `self` target field (ABI v2) — -/// the runtime reader scans a fixed field count, so no legacy shapes exist in -/// fresh compilations. -#[test] -fn unconnected_pins_carry_explicit_self_target() { - let mut g = GraphDescription::new("self_shape"); - g.add_node(begin("be")); - g.add_node(comp_set_node("set", "Light", "intensity", Some(1.0))); - g.add_connection(exec("begin", "be", "set", "set_e")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - let prog = &compiled.programs[0]; - let inputs = prog - .instructions - .iter() - .find_map(|i| match i { - Instruction::Call { - node_type, - input_offsets, - .. - } if node_type == "comp_set_prop::Light::intensity" => Some(input_offsets.clone()), - _ => None, - }) - .expect("set compiles"); - assert_eq!(inputs.len(), 2, "self-targeted: name + value only"); - let blob = prog.instructions.iter().find_map(|i| match i { - Instruction::InitBytes { offset, bytes } if *offset == inputs[0] => Some(bytes.clone()), - _ => None, - }); - let fields = decode_targeted_name_blob(blob.as_deref().unwrap()).expect("decodes"); - assert_eq!( - fields.target, - pbgc::bytecode::comp_ops::RefTarget::SelfActor - ); - // The staged bytes really do end with the `self` field. - let blob = blob.unwrap(); - assert!(blob.ends_with(b"self\0")); -} - -/// #654: a comp_call can be pin-targeted; its name blob then carries argc -/// AND the target field, with the reference operand first among values. -#[test] -fn pin_targeted_call_stages_argc_and_target() { - let mut g = GraphDescription::new("call_targeted"); - g.add_node(begin("be")); - g.add_node(get_ref_node("ref", "Door", 0, false)); - g.add_node(comp_call_node("call", "Door", "open", true)); - g.add_connection(data("ref", "ref_r", "call", "component_ref")); - g.add_connection(exec("begin", "be", "call", "call_e")); - - let compiled = compile_graph_to_bytecode_full(&g, Default::default()).unwrap(); - let prog = &compiled.programs[0]; - let inputs = prog - .instructions - .iter() - .find_map(|i| match i { - Instruction::Call { - node_type, - input_offsets, - .. - } if node_type == "comp_call::Door::open" => Some(input_offsets.clone()), - _ => None, - }) - .expect("call compiles"); - // name + reference operand (no method args on this node). - assert_eq!(inputs.len(), 2); - let blob = prog.instructions.iter().find_map(|i| match i { - Instruction::InitBytes { offset, bytes } if *offset == inputs[0] => Some(bytes.clone()), - _ => None, - }); - let fields = decode_targeted_call_name_blob(&blob.unwrap()).expect("decodes"); - assert_eq!(fields.arg_count, 0); - assert_eq!(fields.target, pbgc::bytecode::comp_ops::RefTarget::RefPin); -} diff --git a/tests/metadata.rs b/tests/metadata.rs new file mode 100644 index 0000000..01354b1 --- /dev/null +++ b/tests/metadata.rs @@ -0,0 +1,27 @@ +use graphy::NodeMetadataProvider; + +#[test] +fn the_standard_library_nodes_are_available_as_graphy_metadata() { + let metadata = pbgc::extract_node_metadata().expect("pulsar_std registry"); + assert!(metadata.len() > 100, "the standard library registers its nodes ({} found)", metadata.len()); + for name in ["add", "branch", "do_once", "delay"] { + assert!(metadata.contains_key(name), "`{name}` is in the catalogue"); + } +} + +#[test] +fn flow_intrinsics_keep_their_exec_pins() { + let node = pbgc::find_node("do_once").expect("do_once"); + assert_eq!(node.exec_outputs, ["Then"]); + let delay = pbgc::find_node("delay").expect("delay"); + assert_eq!(delay.exec_outputs, ["Completed"]); +} + +#[test] +fn the_provider_serves_the_same_catalogue() { + let provider = pbgc::BlueprintMetadataProvider::new(); + assert!(provider.get_node_metadata("add").is_some()); + assert!(provider.get_node_metadata("no_such_node").is_none()); + assert_eq!(provider.get_all_nodes().len(), pbgc::get_node_metadata().len()); + assert!(!pbgc::get_categories().is_empty()); +}