diff --git a/src/bin/check_nbt_world_cycle.rs b/src/bin/check_nbt_world_cycle.rs index 5e103e8..055e55a 100644 --- a/src/bin/check_nbt_world_cycle.rs +++ b/src/bin/check_nbt_world_cycle.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use petgraph::algo::kosaraju_scc; @@ -225,11 +225,89 @@ fn main() -> eyre::Result<()> { print_signal_state(sim.world()); } } + + if std::env::var_os("PRINT_COMPONENTS").is_some() { + print_physical_components(graph, &world_graph.positions, &node_kind_by_id); + } } Ok(()) } +fn print_physical_components( + graph: &redstone_compiler::graph::Graph, + positions: &HashMap, + node_kind_by_id: &HashMap, +) { + let mut neighbors = HashMap::>::new(); + for node in &graph.nodes { + for &output in &node.outputs { + neighbors.entry(node.id).or_default().push(output); + neighbors.entry(output).or_default().push(node.id); + } + } + + let mut visited = HashSet::new(); + let mut components = Vec::new(); + for node in &graph.nodes { + if !visited.insert(node.id) { + continue; + } + let mut queue = VecDeque::from([node.id]); + let mut component = Vec::new(); + while let Some(id) = queue.pop_front() { + component.push(id); + for &next in neighbors.get(&id).into_iter().flatten() { + if visited.insert(next) { + queue.push_back(next); + } + } + } + component.sort(); + components.push(component); + } + components.sort_by_key(|component| std::cmp::Reverse(component.len())); + + println!(" physical_components: {}", components.len()); + for (index, component) in components.iter().take(20).enumerate() { + let has_switch = component + .iter() + .any(|id| node_kind_by_id.get(id).is_some_and(|kind| kind == "Switch")); + let has_torch = component + .iter() + .any(|id| node_kind_by_id.get(id).is_some_and(|kind| kind == "Torch")); + let has_repeater = component + .iter() + .any(|id| node_kind_by_id.get(id).is_some_and(|kind| kind == "Repeater")); + let redstone_count = component + .iter() + .filter(|id| node_kind_by_id.get(id).is_some_and(|kind| kind == "Redstone")) + .count(); + + println!( + " component #{index}: nodes={} redstone={} switch={} torch={} repeater={}", + component.len(), + redstone_count, + has_switch, + has_torch, + has_repeater + ); + for id in component.iter().take(20) { + let Some(pos) = positions.get(id) else { + continue; + }; + let kind = node_kind_by_id + .get(id) + .map(String::as_str) + .unwrap_or("unknown"); + println!(" {id}: {kind} @ ({}, {}, {})", pos.0, pos.1, pos.2); + } + if component.len() > 20 { + println!(" ... {} more", component.len() - 20); + } + } +} + fn print_trace_for_positions(trace: &[SimulationTraceEntry], positions: &[Position]) { for entry in trace.iter().filter(|entry| { positions.iter().any(|pos| { diff --git a/src/output.rs b/src/output.rs index 5cd80d8..c6a6d0e 100644 --- a/src/output.rs +++ b/src/output.rs @@ -11,6 +11,7 @@ const FORMAT: &str = "redstone-compiler.outputs.v1"; #[derive(Debug, Clone)] pub struct PlacedWorld { pub world: World3D, + pub inputs: Vec, pub outputs: Vec, } diff --git a/src/transform/place_and_route/detailed_router.rs b/src/transform/place_and_route/detailed_router.rs new file mode 100644 index 0000000..5209065 --- /dev/null +++ b/src/transform/place_and_route/detailed_router.rs @@ -0,0 +1,234 @@ +use std::collections::HashSet; + +use crate::transform::place_and_route::place_bound::PlaceBound; +use crate::transform::place_and_route::placed_node::PlacedNode; +use crate::world::block::{Block, BlockKind, Direction}; +use crate::world::position::Position; +use crate::world::World3D; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RouteRejectReason { + InitInputTopCobbleConflict, + InitOutputTopCobbleConflict, + OutOfBounds, + NoBottomForCobble, + CobbleConflict, + RedstoneConflict, + DisconnectedRoute, + ShortCircuit, +} + +pub enum PlaceRedstoneResult { + Placed(World3D, PlacedNode), + Rejected(RouteRejectReason), +} + +pub enum PlaceRepeaterResult { + Placed(World3D, PlacedNode), + Rejected(RouteRejectReason), +} + +pub fn place_node(world: &mut World3D, node: PlacedNode) { + if world[node.position] == node.block { + assert!(world[node.position].kind.is_cobble()); + return; + } + + world[node.position] = node.block; + if node.block.kind.is_redstone() { + world.update_redstone_states(node.position); + } +} + +pub fn try_generate_cobble_node( + world: &World3D, + cobble_pos: Position, + except: &[Position], +) -> Option { + if cobble_would_stack_above_side_torch_support(world, cobble_pos) { + return None; + } + let cobble_node = PlacedNode::new_cobble(cobble_pos); + if !cobble_node.has_conflict(world, &except.iter().copied().collect()) { + Some(cobble_node) + } else { + None + } +} + +pub fn cobble_would_stack_above_side_torch_support(world: &World3D, cobble_pos: Position) -> bool { + let Some(below) = cobble_pos.down() else { + return false; + }; + world.size.bound_on(below) + && world[below].kind.is_cobble() + && below.cardinal().into_iter().any(|position| { + world.size.bound_on(position) + && matches!(world[position].kind, BlockKind::Torch { .. }) + && world[position].direction == position.diff(below) + }) +} + +pub fn place_redstone_with_cobble( + world: &World3D, + bound: PlaceBound, + prev: Position, + to: Position, +) -> PlaceRedstoneResult { + place_redstone_with_cobble_and_allowed_shorts(world, bound, prev, to, None) +} + +pub fn place_redstone_with_cobble_and_allowed_shorts( + world: &World3D, + bound: PlaceBound, + prev: Position, + to: Position, + allowed_shorts: Option<&HashSet>, +) -> PlaceRedstoneResult { + let Some(cobble_pos) = bound.position().walk(Direction::Bottom) else { + return PlaceRedstoneResult::Rejected(RouteRejectReason::NoBottomForCobble); + }; + let cobble_except = (world[prev].kind.is_torch()) + .then_some(vec![cobble_pos, prev]) + .unwrap_or_default(); + let Some(cobble_node) = try_generate_cobble_node(world, cobble_pos, &cobble_except) else { + return PlaceRedstoneResult::Rejected(RouteRejectReason::CobbleConflict); + }; + let mut new_world = world.clone(); + place_node(&mut new_world, cobble_node); + + let bound_pos = bound.position(); + let Some(bound_back_pos) = bound_pos.walk(bound.direction()) else { + return PlaceRedstoneResult::Rejected(RouteRejectReason::RedstoneConflict); + }; + let redstone_node = PlacedNode::new_redstone(bound_pos); + let mut except = [prev, bound_back_pos, bound_pos, to, to.up()] + .into_iter() + .collect::>(); + if let Some(allowed_shorts) = allowed_shorts { + except.extend(allowed_shorts.iter().copied()); + } + let mut short_except = [prev, bound_pos, to, to.up()] + .into_iter() + .collect::>(); + if let Some(allowed_shorts) = allowed_shorts { + short_except.extend(allowed_shorts.iter().copied()); + } + if redstone_node.has_conflict(&new_world, &except) { + return PlaceRedstoneResult::Rejected(RouteRejectReason::RedstoneConflict); + } + if redstone_node.has_short(world, &short_except) { + return PlaceRedstoneResult::Rejected(RouteRejectReason::ShortCircuit); + } + place_node(&mut new_world, redstone_node); + new_world.update_redstone_states(prev); + if !target_powers_redstone(&new_world, prev, redstone_node.position) { + return PlaceRedstoneResult::Rejected(RouteRejectReason::DisconnectedRoute); + } + if redstone_node.has_short(&new_world, &short_except) { + return PlaceRedstoneResult::Rejected(RouteRejectReason::ShortCircuit); + } + if let BlockKind::Torch { .. } = world[prev].kind { + if let Some(source_cobble) = prev.walk(world[prev].direction) { + if redstone_powers_cobble(&new_world, redstone_node.position, source_cobble) { + return PlaceRedstoneResult::Rejected(RouteRejectReason::ShortCircuit); + } + } + } + + PlaceRedstoneResult::Placed(new_world, redstone_node) +} + +pub fn place_repeater_with_cobble( + world: &World3D, + bound: PlaceBound, + prev: Position, + to: Position, + direction: Direction, + allowed_shorts: Option<&HashSet>, +) -> PlaceRepeaterResult { + let Some(cobble_pos) = bound.position().walk(Direction::Bottom) else { + return PlaceRepeaterResult::Rejected(RouteRejectReason::NoBottomForCobble); + }; + let Some(cobble_node) = try_generate_cobble_node(world, cobble_pos, &[]) else { + return PlaceRepeaterResult::Rejected(RouteRejectReason::CobbleConflict); + }; + let mut new_world = world.clone(); + place_node(&mut new_world, cobble_node); + + let repeater_node = PlacedNode { + position: bound.position(), + block: Block { + kind: BlockKind::Repeater { + is_on: false, + is_locked: false, + delay: 1, + lock_input1: None, + lock_input2: None, + }, + direction, + }, + }; + let mut except = [prev, bound.position(), to, to.up()] + .into_iter() + .collect::>(); + if let Some(allowed_shorts) = allowed_shorts { + except.extend(allowed_shorts.iter().copied()); + } + if repeater_node.has_conflict(&new_world, &except) { + return PlaceRepeaterResult::Rejected(RouteRejectReason::RedstoneConflict); + } + place_node(&mut new_world, repeater_node); + if !target_powers_position(&new_world, prev, repeater_node.position) { + return PlaceRepeaterResult::Rejected(RouteRejectReason::DisconnectedRoute); + } + + PlaceRepeaterResult::Placed(new_world, repeater_node) +} + +pub fn redstone_powers_cobble(world: &World3D, redstone: Position, cobble: Position) -> bool { + if !world[redstone].kind.is_redstone() { + return false; + } + world[cobble].kind.is_cobble() + && PlacedNode::new(redstone, world[redstone]) + .propagation_bound(Some(world)) + .into_iter() + .any(|bound| bound.position() == cobble) +} + +pub fn target_powers_redstone(world: &World3D, target: Position, redstone: Position) -> bool { + if world[target].kind.is_air() || world[target].kind.is_cobble() { + return false; + } + let target_node = PlacedNode::new(target, world[target]); + target_node + .propagation_bound(Some(world)) + .into_iter() + .filter(|bound| bound.is_bound_on(world)) + .any(|bound| { + bound.position() == redstone + || bound + .propagate_to(world) + .into_iter() + .any(|(_, position)| position == redstone) + }) +} + +pub fn target_powers_position(world: &World3D, target: Position, position: Position) -> bool { + if world[target].kind.is_air() || world[target].kind.is_cobble() { + return false; + } + let target_node = PlacedNode::new(target, world[target]); + target_node + .propagation_bound(Some(world)) + .into_iter() + .filter(|bound| bound.is_bound_on(world)) + .any(|bound| { + bound.position() == position + || bound + .propagate_to(world) + .into_iter() + .any(|(_, propagated)| propagated == position) + }) +} diff --git a/src/transform/place_and_route/global_pnr/assembly.rs b/src/transform/place_and_route/global_pnr/assembly.rs new file mode 100644 index 0000000..19b0f30 --- /dev/null +++ b/src/transform/place_and_route/global_pnr/assembly.rs @@ -0,0 +1,90 @@ +use std::fmt; + +use crate::transform::place_and_route::global_pnr::ir::LayoutCandidate; +use crate::transform::place_and_route::global_pnr::placer::PlacedModule; +use crate::transform::place_and_route::global_pnr::router::RoutedNet; +use crate::world::block::Block; +use crate::world::position::{DimSize, Position}; +use crate::world::World3D; + +#[derive(Debug, PartialEq, Eq)] +pub enum AssemblyError { + MissingCandidate { candidate_index: usize }, + Collision { position: Position }, +} + +impl fmt::Display for AssemblyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AssemblyError::MissingCandidate { candidate_index } => { + write!(f, "missing layout candidate at index {candidate_index}") + } + AssemblyError::Collision { position } => { + write!(f, "assembly block collision at {position:?}") + } + } + } +} + +impl std::error::Error for AssemblyError {} + +pub fn assemble_world( + candidates: &[LayoutCandidate], + placed_modules: &[PlacedModule], + routed_nets: &[RoutedNet], +) -> Result { + let mut blocks = Vec::<(Position, Block)>::new(); + + for placed in placed_modules { + let candidate = + candidates + .get(placed.candidate_index) + .ok_or(AssemblyError::MissingCandidate { + candidate_index: placed.candidate_index, + })?; + for (position, block) in candidate.world.iter_block() { + blocks.push(( + translate_candidate_position(position, candidate, placed), + block, + )); + } + } + + for route in routed_nets { + blocks.extend(route.blocks.iter().copied()); + } + + let size = world_size_for_blocks(&blocks); + let mut world = World3D::new(size); + for (position, block) in blocks { + if !world[position].kind.is_air() { + return Err(AssemblyError::Collision { position }); + } + world[position] = block; + } + world.initialize_redstone_states(); + + Ok(world) +} + +fn translate_candidate_position( + position: Position, + candidate: &LayoutCandidate, + placed: &PlacedModule, +) -> Position { + Position( + placed.origin.0 + position.0 - candidate.bbox.min.0, + placed.origin.1 + position.1 - candidate.bbox.min.1, + placed.origin.2 + position.2 - candidate.bbox.min.2, + ) +} + +fn world_size_for_blocks(blocks: &[(Position, Block)]) -> DimSize { + let mut max = Position(0, 0, 0); + for (position, _) in blocks { + max.0 = max.0.max(position.0); + max.1 = max.1.max(position.1); + max.2 = max.2.max(position.2); + } + DimSize(max.0 + 1, max.1 + 1, max.2 + 1) +} diff --git a/src/transform/place_and_route/global_pnr/candidate.rs b/src/transform/place_and_route/global_pnr/candidate.rs new file mode 100644 index 0000000..a94d45f --- /dev/null +++ b/src/transform/place_and_route/global_pnr/candidate.rs @@ -0,0 +1,177 @@ +use eyre::ContextCompat; +use itertools::Itertools; + +use crate::graph::logic::LogicGraph; +use crate::graph::module::{GraphModule, GraphModulePortTarget, GraphModulePortType}; +use crate::transform::place_and_route::detailed_router; +use crate::transform::place_and_route::global_pnr::ir::{ + LayoutCandidate, PhysicalPort, PhysicalPortDirection, +}; +use crate::transform::place_and_route::local_placer::{ + LocalPlacer, LocalPlacerConfig, LocalPlacerInputConstraints, +}; +use crate::world::block::Block; +use crate::world::position::{DimSize, Position}; +use crate::world::World3D; + +#[derive(Clone)] +pub struct UnitCandidateConfig { + pub dim: DimSize, + pub local_config: LocalPlacerConfig, + pub input_constraints: LocalPlacerInputConstraints, + pub max_candidates: usize, +} + +impl Default for UnitCandidateConfig { + fn default() -> Self { + Self { + dim: DimSize(16, 16, 6), + local_config: LocalPlacerConfig::default(), + input_constraints: LocalPlacerInputConstraints::default(), + max_candidates: 16, + } + } +} + +pub fn generate_graph_module_candidates( + module: &GraphModule, + config: &UnitCandidateConfig, +) -> eyre::Result> { + let graph = module + .graph + .clone() + .context("only graph-backed GraphModule can generate unit layout candidates")?; + let graph = LogicGraph { graph }.prepare_place()?; + let placer = LocalPlacer::new(graph, config.local_config)?; + + placer + .generate_with_outputs_and_input_constraints(config.dim, None, &config.input_constraints) + .into_iter() + .take(config.max_candidates) + .map(|placed| { + let (world, ports) = switchless_candidate_layout( + module, + &config.input_constraints, + placed.world, + &placed.inputs, + &placed.outputs, + ); + LayoutCandidate::from_world(module.name.clone(), world, ports) + }) + .collect() +} + +fn switchless_candidate_layout( + module: &GraphModule, + input_constraints: &LocalPlacerInputConstraints, + mut world: World3D, + inputs: &[crate::output::OutputEndpoint], + outputs: &[crate::output::OutputEndpoint], +) -> (World3D, Vec) { + let mut ports = module + .ports + .iter() + .filter_map(|port| match (&port.port_type, &port.target) { + (GraphModulePortType::InputNet, GraphModulePortTarget::Node(input_name)) => inputs + .iter() + .find(|input| input.name == *input_name) + .map(|input| input.position()) + .or_else(|| { + input_constraints + .positions_for_input_name(input_name) + .and_then(|positions| positions.into_iter().next()) + }) + .and_then(|position| { + expose_switchless_input_port(&mut world, position).map(|position| { + PhysicalPort { + name: port.name.clone(), + direction: PhysicalPortDirection::Input, + position, + } + }) + }), + (GraphModulePortType::OutputNet, GraphModulePortTarget::Node(output_name)) => outputs + .iter() + .find(|output| output.name == *output_name) + .map(|output| PhysicalPort { + name: port.name.clone(), + direction: PhysicalPortDirection::Output, + position: expose_routeable_output_port(&world, output.position()), + }), + _ => None, + }) + .collect_vec(); + for input in inputs { + let _ = expose_switchless_input_port(&mut world, input.position()); + } + remove_local_input_switches(&mut world); + ports.sort_by(|a, b| a.name.cmp(&b.name)); + world.initialize_redstone_states(); + (world, ports) +} + +fn remove_local_input_switches(world: &mut World3D) { + for (position, block) in world.iter_block() { + if block.kind.is_switch() { + world[position] = Block::default(); + } + } +} + +fn expose_routeable_output_port(world: &World3D, output_position: Position) -> Position { + if !world.size.bound_on(output_position) + || (!world[output_position].kind.is_torch() + && !world[output_position].kind.is_switch() + && !world[output_position].kind.is_repeater()) + { + return output_position; + } + + world + .iter_block() + .into_iter() + .filter(|(position, block)| { + block.kind.is_redstone() + && detailed_router::target_powers_redstone(world, output_position, *position) + }) + .map(|(position, _)| position) + .min_by_key(|position| { + ( + output_position.manhattan_distance(position), + position.0, + position.1, + position.2, + ) + }) + .unwrap_or(output_position) +} + +fn expose_switchless_input_port(world: &mut World3D, input_position: Position) -> Option { + if !world.size.bound_on(input_position) { + return None; + } + if !world[input_position].kind.is_switch() { + return Some(input_position); + } + + let switch_target = input_position.walk(world[input_position].direction); + let port_position = switch_target + .filter(|position| world.size.bound_on(*position) && world[*position].kind.is_cobble()) + .unwrap_or_else(|| expose_routeable_output_port(world, input_position)); + if port_position == input_position { + return None; + } + world[input_position] = Block::default(); + Some(port_position) +} + +pub fn d_latch_child_candidate_config(local_config: LocalPlacerConfig) -> UnitCandidateConfig { + UnitCandidateConfig { + dim: DimSize(14, 10, 6), + local_config, + input_constraints: LocalPlacerInputConstraints::new() + .with_input_positions("d", [Position(0, 2, 1)]) + .with_input_positions("en", [Position(0, 6, 1)]), + max_candidates: 1, + } +} diff --git a/src/transform/place_and_route/global_pnr/ir.rs b/src/transform/place_and_route/global_pnr/ir.rs new file mode 100644 index 0000000..66332db --- /dev/null +++ b/src/transform/place_and_route/global_pnr/ir.rs @@ -0,0 +1,66 @@ +use std::collections::HashSet; + +use eyre::ContextCompat; + +use crate::transform::place_and_route::estimate::{bounding_box, BoundingBox}; +use crate::world::position::Position; +use crate::world::World3D; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PhysicalPortDirection { + Input, + Output, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhysicalPort { + pub name: String, + pub direction: PhysicalPortDirection, + pub position: Position, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LayoutCandidateCost { + pub block_count: usize, + pub bbox_volume: usize, +} + +#[derive(Clone, Debug)] +pub struct LayoutCandidate { + pub module_name: String, + pub world: World3D, + pub bbox: BoundingBox, + pub ports: Vec, + pub occupied_cells: HashSet, + pub blocked_cells: HashSet, + pub cost: LayoutCandidateCost, +} + +impl LayoutCandidate { + pub fn from_world( + module_name: String, + world: World3D, + ports: Vec, + ) -> eyre::Result { + let bbox = bounding_box(&world).context("layout candidate world has no blocks")?; + let occupied_cells = world + .iter_block() + .into_iter() + .map(|(position, _)| position) + .collect::>(); + let cost = LayoutCandidateCost { + block_count: occupied_cells.len(), + bbox_volume: bbox.volume(), + }; + + Ok(Self { + module_name, + world, + bbox, + ports, + occupied_cells, + blocked_cells: HashSet::new(), + cost, + }) + } +} diff --git a/src/transform/place_and_route/global_pnr/mod.rs b/src/transform/place_and_route/global_pnr/mod.rs new file mode 100644 index 0000000..ba4b5c7 --- /dev/null +++ b/src/transform/place_and_route/global_pnr/mod.rs @@ -0,0 +1,259 @@ +pub mod assembly; +pub mod candidate; +pub mod ir; +pub mod placer; +pub mod router; + +use eyre::ContextCompat; + +use crate::graph::module::{GraphModule, GraphModuleContext}; +use crate::transform::place_and_route::global_pnr::assembly::assemble_world; +use crate::transform::place_and_route::global_pnr::candidate::{ + generate_graph_module_candidates, UnitCandidateConfig, +}; +use crate::transform::place_and_route::global_pnr::placer::{ + place_candidates_on_shelves, GlobalPlacementConfig, +}; +use crate::transform::place_and_route::global_pnr::router::route_module_variables; +use crate::world::World3D; + +#[derive(Clone, Default)] +pub struct GlobalPnrConfig { + pub candidate: UnitCandidateConfig, + pub placement: GlobalPlacementConfig, +} + +pub fn place_and_route_module( + context: &GraphModuleContext, + module: &GraphModule, + config: &GlobalPnrConfig, +) -> eyre::Result { + if module.graph.is_some() { + let candidates = generate_graph_module_candidates(module, &config.candidate)?; + let candidate = candidates + .into_iter() + .next() + .context("graph-backed module produced no layout candidates")?; + let placed = place_candidates_on_shelves(&[candidate.clone()], &config.placement); + return assemble_world(&[candidate], &placed, &[]).map_err(Into::into); + } + + let mut candidates = Vec::new(); + for instance in &module.instances { + let child = &context[instance.as_str()]; + let mut child_candidates = generate_graph_module_candidates(child, &config.candidate)?; + let candidate = child_candidates + .drain(..) + .next() + .with_context(|| format!("module instance `{instance}` produced no candidates"))?; + candidates.push(candidate); + } + + let placed = place_candidates_on_shelves(&candidates, &config.placement); + let routed_nets = route_module_variables(module, &candidates, &placed)?; + assemble_world(&candidates, &placed, &routed_nets).map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::logic::LogicGraph; + use crate::graph::module::{ + GraphModule, GraphModuleContext, GraphModulePort, GraphModulePortTarget, + GraphModulePortType, GraphModuleVariable, + }; + use crate::graph::{Graph, GraphNode, GraphNodeKind}; + use crate::nbt::{NBTRoot, ToNBT}; + use crate::sequential::{SequentialPrimitive, SequentialType}; + use crate::transform::place_and_route::global_pnr::candidate::d_latch_child_candidate_config; + use crate::transform::place_and_route::local_placer::{ + InputPlacementStrategy, LocalPlacerConfig, NotRouteStrategy, PlacementSamplingPolicy, + TorchPlacementStrategy, + }; + use crate::transform::place_and_route::sampling::SamplingPolicy; + use crate::world::position::DimSize; + + fn sequential_local_config() -> LocalPlacerConfig { + LocalPlacerConfig { + random_seed: 42, + greedy_input_generation: true, + input_placement_strategy: InputPlacementStrategy::Boundary, + input_candidate_limit: None, + step_sampling_policy: SamplingPolicy::Random(256), + placement_sampling_policy: PlacementSamplingPolicy::StepPolicy, + leak_sampling: false, + route_torch_directly: true, + materialize_outputs: false, + torch_placement_strategy: TorchPlacementStrategy::DirectOnly, + not_route_strategy: NotRouteStrategy::DirectAndRedstone, + max_not_route_step: 4, + not_route_step_sampling_policy: SamplingPolicy::Random(256), + max_route_step: 4, + route_step_sampling_policy: SamplingPolicy::Random(256), + } + } + + #[test] + fn graph_backed_module_generates_world_with_global_pnr_api() -> eyre::Result<()> { + let mut module: GraphModule = LogicGraph::from_stmt("~a", "not_a")?.graph.into(); + module.name = "not_gate".to_owned(); + let context = GraphModuleContext::default(); + let config = GlobalPnrConfig { + candidate: UnitCandidateConfig { + dim: DimSize(8, 8, 4), + max_candidates: 1, + ..Default::default() + }, + placement: GlobalPlacementConfig::default(), + }; + + let world = place_and_route_module(&context, &module, &config)?; + + assert!(!world.iter_block().is_empty()); + Ok(()) + } + + #[test] + #[ignore = "search-heavy sequential global pnr smoke test"] + fn d_flip_flop_module_generates_world_from_child_layout_candidates() -> eyre::Result<()> { + let mut context = GraphModuleContext::default(); + context.append(not_clk_module()); + context.append(d_latch_module("master")); + context.append(d_latch_module("slave")); + let module = d_flip_flop_module(); + context.append(module.clone()); + let config = GlobalPnrConfig { + candidate: d_latch_child_candidate_config(sequential_local_config()), + placement: GlobalPlacementConfig { + spacing: 4, + shelf_width: 64, + }, + }; + + let world = place_and_route_module(&context, &module, &config)?; + + assert!(!world.iter_block().is_empty()); + let nbt: NBTRoot = world.to_nbt(); + nbt.save("test/d-flip-flop-global-smoke.nbt"); + Ok(()) + } + + fn d_flip_flop_module() -> GraphModule { + GraphModule { + name: "d_flip_flop".to_owned(), + graph: None, + instances: vec![ + "not_clk".to_owned(), + "master".to_owned(), + "slave".to_owned(), + ], + vars: vec![ + GraphModuleVariable { + var_type: GraphModulePortType::InputNet, + source: ("not_clk".to_owned(), "clk_n".to_owned()), + target: ("master".to_owned(), "en".to_owned()), + }, + GraphModuleVariable { + var_type: GraphModulePortType::InputNet, + source: ("master".to_owned(), "q".to_owned()), + target: ("slave".to_owned(), "d".to_owned()), + }, + ], + ports: vec![ + GraphModulePort { + name: "d".to_owned(), + port_type: GraphModulePortType::InputNet, + target: GraphModulePortTarget::Module("master".to_owned(), "d".to_owned()), + }, + GraphModulePort { + name: "clk".to_owned(), + port_type: GraphModulePortType::InputNet, + target: GraphModulePortTarget::Wire(vec![ + ("not_clk".to_owned(), "clk".to_owned()), + ("slave".to_owned(), "en".to_owned()), + ]), + }, + GraphModulePort { + name: "q".to_owned(), + port_type: GraphModulePortType::OutputNet, + target: GraphModulePortTarget::Module("slave".to_owned(), "q".to_owned()), + }, + ], + } + } + + fn not_clk_module() -> GraphModule { + let mut graph = Graph { + nodes: vec![ + GraphNode { + id: 0, + kind: GraphNodeKind::Input("clk".to_owned()), + ..Default::default() + }, + GraphNode { + id: 1, + kind: GraphNodeKind::Logic(crate::logic::Logic { + logic_type: crate::logic::LogicType::Not, + }), + inputs: vec![0], + ..Default::default() + }, + GraphNode { + id: 2, + kind: GraphNodeKind::Output("clk_n".to_owned()), + inputs: vec![1], + ..Default::default() + }, + ], + ..Default::default() + }; + graph.build_outputs(); + graph.build_producers(); + graph.build_consumers(); + graph.verify().unwrap(); + let mut module: GraphModule = graph.into(); + module.name = "not_clk".to_owned(); + module + } + + fn d_latch_module(name: &str) -> GraphModule { + let mut graph = Graph { + nodes: vec![ + GraphNode { + id: 0, + kind: GraphNodeKind::Input("d".to_owned()), + ..Default::default() + }, + GraphNode { + id: 1, + kind: GraphNodeKind::Input("en".to_owned()), + ..Default::default() + }, + GraphNode { + id: 2, + kind: GraphNodeKind::Sequential(SequentialPrimitive::new( + SequentialType::DLatch, + vec!["d".to_owned(), "en".to_owned()], + vec!["q".to_owned()], + )), + inputs: vec![0, 1], + ..Default::default() + }, + GraphNode { + id: 3, + kind: GraphNodeKind::Output("q".to_owned()), + inputs: vec![2], + ..Default::default() + }, + ], + ..Default::default() + }; + graph.build_outputs(); + graph.build_producers(); + graph.build_consumers(); + graph.verify().unwrap(); + let mut module: GraphModule = graph.into(); + module.name = name.to_owned(); + module + } +} diff --git a/src/transform/place_and_route/global_pnr/placer.rs b/src/transform/place_and_route/global_pnr/placer.rs new file mode 100644 index 0000000..c29b9cc --- /dev/null +++ b/src/transform/place_and_route/global_pnr/placer.rs @@ -0,0 +1,59 @@ +use crate::transform::place_and_route::estimate::BoundingBox; +use crate::transform::place_and_route::global_pnr::ir::LayoutCandidate; +use crate::world::position::Position; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GlobalPlacementConfig { + pub spacing: usize, + pub shelf_width: usize, +} + +impl Default for GlobalPlacementConfig { + fn default() -> Self { + Self { + spacing: 2, + shelf_width: 64, + } + } +} + +#[derive(Clone, Debug)] +pub struct PlacedModule { + pub module_name: String, + pub candidate_index: usize, + pub origin: Position, + pub bbox: BoundingBox, +} + +pub fn place_candidates_on_shelves( + candidates: &[LayoutCandidate], + config: &GlobalPlacementConfig, +) -> Vec { + let mut placed = Vec::new(); + let mut cursor_x = 0usize; + let mut cursor_y = 0usize; + let mut shelf_depth = 0usize; + + for (candidate_index, candidate) in candidates.iter().enumerate() { + let width = candidate.bbox.width(); + let depth = candidate.bbox.depth(); + + if cursor_x > 0 && cursor_x + width > config.shelf_width { + cursor_x = 0; + cursor_y += shelf_depth + config.spacing; + shelf_depth = 0; + } + + placed.push(PlacedModule { + module_name: candidate.module_name.clone(), + candidate_index, + origin: Position(cursor_x, cursor_y, candidate.bbox.min.2), + bbox: candidate.bbox, + }); + + cursor_x += width + config.spacing; + shelf_depth = shelf_depth.max(depth); + } + + placed +} diff --git a/src/transform/place_and_route/global_pnr/router.rs b/src/transform/place_and_route/global_pnr/router.rs new file mode 100644 index 0000000..a28da6e --- /dev/null +++ b/src/transform/place_and_route/global_pnr/router.rs @@ -0,0 +1,663 @@ +use std::collections::{HashSet, VecDeque}; + +use eyre::ContextCompat; + +use crate::graph::module::{GraphModule, GraphModulePortTarget}; +use crate::transform::place_and_route::detailed_router::{ + self, PlaceRedstoneResult, PlaceRepeaterResult, +}; +use crate::transform::place_and_route::global_pnr::ir::LayoutCandidate; +use crate::transform::place_and_route::global_pnr::placer::PlacedModule; +use crate::transform::place_and_route::place_bound::{PlaceBound, PropagateType}; +use crate::transform::place_and_route::placed_node::PlacedNode; +use crate::world::block::{Block, BlockKind, Direction}; +use crate::world::position::{DimSize, Position}; +use crate::world::World3D; + +const GLOBAL_ROUTE_PADDING: usize = 8; +const GLOBAL_ROUTE_MAX_STEPS: usize = 128; +const MAX_REDSTONE_STRENGTH: usize = 15; + +#[derive(Clone, Debug)] +pub struct RoutedNet { + pub source: Position, + pub sink: Position, + pub blocks: Vec<(Position, Block)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RouteFailure { + Unreachable { source: Position, sink: Position }, +} + +pub fn route_module_variables( + module: &GraphModule, + candidates: &[LayoutCandidate], + placed_modules: &[PlacedModule], +) -> eyre::Result> { + let mut route_world = placed_candidate_world(candidates, placed_modules)?; + let mut routes = Vec::new(); + let mut top_input_index = 0; + + for port in module.ports.iter().filter(|port| port.port_type.is_input()) { + let sinks = resolve_port_target_positions(candidates, placed_modules, &port.target); + if sinks.is_empty() { + continue; + } + + let source = external_switch_position(&route_world, top_input_index) + .with_context(|| format!("failed to place top-level input switch `{}`", port.name))?; + top_input_index += 1; + let switch = input_switch_block(); + route_world[source] = switch; + routes.push(RoutedNet { + source, + sink: source, + blocks: vec![(source, switch)], + }); + + for sink in sinks { + let (route, next_world) = + route_point_to_point(&route_world, source, sink).map_err(|failure| { + eyre::eyre!( + "failed to route top-level input {} -> {sink:?}: {failure:?}", + port.name + ) + })?; + route_world = next_world; + routes.push(route); + } + } + + for var in &module.vars { + let source = + resolve_port_position(candidates, placed_modules, &var.source.0, &var.source.1) + .with_context(|| { + format!( + "source port {}.{} is not placed", + var.source.0, var.source.1 + ) + })?; + let sink = resolve_port_position(candidates, placed_modules, &var.target.0, &var.target.1) + .with_context(|| { + format!( + "target port {}.{} is not placed", + var.target.0, var.target.1 + ) + })?; + let (route, next_world) = + route_point_to_point(&route_world, source, sink).map_err(|failure| { + eyre::eyre!( + "failed to route {}.{} -> {}.{}: {failure:?}", + var.source.0, + var.source.1, + var.target.0, + var.target.1 + ) + })?; + route_world = next_world; + routes.push(route); + } + + Ok(routes) +} + +fn resolve_port_target_positions( + candidates: &[LayoutCandidate], + placed_modules: &[PlacedModule], + target: &GraphModulePortTarget, +) -> Vec { + match target { + GraphModulePortTarget::Module(module_name, port_name) => { + resolve_port_position(candidates, placed_modules, module_name, port_name) + .into_iter() + .collect() + } + GraphModulePortTarget::Wire(targets) => targets + .iter() + .filter_map(|(module_name, port_name)| { + resolve_port_position(candidates, placed_modules, module_name, port_name) + }) + .collect(), + GraphModulePortTarget::Node(_) => Vec::new(), + } +} + +fn external_switch_position(world: &World3D, index: usize) -> Option { + let max_x = world + .iter_block() + .into_iter() + .map(|(position, _)| position.0) + .max() + .unwrap_or(0); + let position = Position(max_x + 2, index * 3 + 1, 1); + (world.size.bound_on(position) && world[position].kind.is_air()).then_some(position) +} + +fn input_switch_block() -> Block { + Block { + kind: BlockKind::Switch { is_on: false }, + direction: Direction::West, + } +} + +fn resolve_port_position( + candidates: &[LayoutCandidate], + placed_modules: &[PlacedModule], + module_name: &str, + port_name: &str, +) -> Option { + let placed = placed_modules + .iter() + .find(|placed| placed.module_name == module_name)?; + let candidate = candidates.get(placed.candidate_index)?; + let port = candidate.ports.iter().find(|port| port.name == port_name)?; + Some(translate_candidate_position( + port.position, + candidate, + placed, + )) +} + +fn placed_candidate_world( + candidates: &[LayoutCandidate], + placed_modules: &[PlacedModule], +) -> eyre::Result { + let blocks = translated_candidate_blocks(candidates, placed_modules)?; + let mut world = World3D::new(route_world_size(&blocks)); + for (position, block) in blocks { + if !world[position].kind.is_air() { + eyre::bail!("global route base collision at {position:?}"); + } + world[position] = block; + } + world.initialize_redstone_states(); + Ok(world) +} + +fn translated_candidate_blocks( + candidates: &[LayoutCandidate], + placed_modules: &[PlacedModule], +) -> eyre::Result> { + let mut blocks = Vec::new(); + for placed in placed_modules { + let candidate = candidates + .get(placed.candidate_index) + .with_context(|| format!("missing candidate {}", placed.candidate_index))?; + blocks.extend( + candidate + .world + .iter_block() + .into_iter() + .map(|(position, block)| { + ( + translate_candidate_position(position, candidate, placed), + block, + ) + }), + ); + } + Ok(blocks) +} + +fn route_world_size(blocks: &[(Position, Block)]) -> DimSize { + let mut max = Position(0, 0, 0); + for (position, _) in blocks { + max.0 = max.0.max(position.0); + max.1 = max.1.max(position.1); + max.2 = max.2.max(position.2); + } + DimSize( + max.0 + GLOBAL_ROUTE_PADDING + 1, + max.1 + GLOBAL_ROUTE_PADDING + 1, + max.2 + GLOBAL_ROUTE_PADDING + 1, + ) +} + +fn translate_candidate_position( + position: Position, + candidate: &LayoutCandidate, + placed: &PlacedModule, +) -> Position { + Position( + placed.origin.0 + position.0 - candidate.bbox.min.0, + placed.origin.1 + position.1 - candidate.bbox.min.1, + placed.origin.2 + position.2 - candidate.bbox.min.2, + ) +} + +pub fn route_point_to_point( + world: &World3D, + source: Position, + sink: Position, +) -> Result<(RoutedNet, World3D), RouteFailure> { + let goal = RouteGoal::for_sink(world, sink); + route_point_to_point_with_bounds(world, source, sink, goal, BoundSearchMode::Propagation) + .or_else(|_| { + route_point_to_point_with_bounds(world, source, sink, goal, BoundSearchMode::Nearby) + }) +} + +#[derive(Clone, Copy, Debug)] +enum BoundSearchMode { + Propagation, + Nearby, +} + +fn route_point_to_point_with_bounds( + world: &World3D, + source: Position, + sink: Position, + goal: RouteGoal, + mode: BoundSearchMode, +) -> Result<(RoutedNet, World3D), RouteFailure> { + let initial_strength = initial_signal_strength(world, source); + let mut queue = VecDeque::from([RouteSearchState { + world: world.clone(), + terminal: source, + route: vec![source], + signal_strength: initial_strength, + }]); + let mut visited = HashSet::from([(source, 0, initial_strength)]); + + while let Some(state) = queue.pop_front() { + if !is_route_terminal(&state.world, state.terminal) { + continue; + } + if goal.accepts(&state.world, state.terminal) { + let blocks = added_route_blocks(world, &state.world); + return Ok(( + RoutedNet { + source, + sink, + blocks, + }, + state.world, + )); + } + + if state.route.len() > GLOBAL_ROUTE_MAX_STEPS { + continue; + } + + let terminal_node = PlacedNode::new(state.terminal, state.world[state.terminal]); + let allowed_shorts = goal.allowed_short_positions(); + for bound in route_bounds_for_mode(mode, &state.world, &terminal_node) { + if !bound.is_bound_on(&state.world) || goal.rejects_bound_position(bound.position()) { + continue; + } + + if state.signal_strength > 1 { + match detailed_router::place_redstone_with_cobble_and_allowed_shorts( + &state.world, + bound, + state.terminal, + goal.placement_target(), + allowed_shorts.as_ref(), + ) { + PlaceRedstoneResult::Placed(next_world, redstone_node) => { + let next_strength = state.signal_strength - 1; + if visited.insert(( + redstone_node.position, + state.route.len(), + next_strength, + )) { + let mut next_route = state.route.clone(); + next_route.push(redstone_node.position); + queue.push_back(RouteSearchState { + world: next_world, + terminal: redstone_node.position, + route: next_route, + signal_strength: next_strength, + }); + } + } + PlaceRedstoneResult::Rejected(_) => {} + } + } + + if state.signal_strength <= 2 { + for direction in Direction::iter_direction_without_top() + .into_iter() + .filter(|direction| direction.is_cardinal()) + { + match detailed_router::place_repeater_with_cobble( + &state.world, + bound, + state.terminal, + goal.placement_target(), + direction, + allowed_shorts.as_ref(), + ) { + PlaceRepeaterResult::Placed(next_world, repeater_node) => { + if visited.insert(( + repeater_node.position, + state.route.len(), + MAX_REDSTONE_STRENGTH, + )) { + let mut next_route = state.route.clone(); + next_route.push(repeater_node.position); + queue.push_back(RouteSearchState { + world: next_world, + terminal: repeater_node.position, + route: next_route, + signal_strength: MAX_REDSTONE_STRENGTH, + }); + } + } + PlaceRepeaterResult::Rejected(_) => {} + } + } + } + } + } + + Err(RouteFailure::Unreachable { source, sink }) +} + +#[derive(Clone)] +struct RouteSearchState { + world: World3D, + terminal: Position, + route: Vec, + signal_strength: usize, +} + +fn initial_signal_strength(world: &World3D, source: Position) -> usize { + match world[source].kind { + BlockKind::Redstone { strength, .. } if strength > 0 => strength, + BlockKind::Redstone { .. } + | BlockKind::Switch { .. } + | BlockKind::Torch { .. } + | BlockKind::Repeater { .. } + | BlockKind::RedstoneBlock => MAX_REDSTONE_STRENGTH, + _ => 0, + } +} + +fn is_route_terminal(world: &World3D, position: Position) -> bool { + world[position].kind.is_redstone() + || world[position].kind.is_switch() + || world[position].kind.is_torch() + || world[position].kind.is_repeater() + || matches!( + world[position].kind, + crate::world::block::BlockKind::RedstoneBlock + ) +} + +#[derive(Clone, Copy)] +enum RouteGoal { + ConnectPosition { target: Position }, + PowerCobble { cobble: Position }, + PlaceRedstone { target: Position }, +} + +impl RouteGoal { + fn for_sink(world: &World3D, sink: Position) -> Self { + if world[sink].kind.is_cobble() { + return Self::PowerCobble { cobble: sink }; + } + if world[sink].kind.is_air() { + return Self::PlaceRedstone { target: sink }; + } + Self::ConnectPosition { target: sink } + } + + fn placement_target(self) -> Position { + match self { + Self::ConnectPosition { target } => target, + Self::PowerCobble { cobble } => cobble, + Self::PlaceRedstone { target } => target, + } + } + + fn rejects_bound_position(self, position: Position) -> bool { + match self { + Self::PlaceRedstone { .. } => false, + _ => position == self.placement_target(), + } + } + + fn accepts(self, world: &World3D, redstone: Position) -> bool { + match self { + Self::ConnectPosition { target } => { + detailed_router::target_powers_redstone(world, target, redstone) + } + Self::PowerCobble { cobble } => { + detailed_router::redstone_powers_cobble(world, redstone, cobble) + } + Self::PlaceRedstone { target } => { + redstone == target && world[redstone].kind.is_redstone() + } + } + } + + fn allowed_short_positions(self) -> Option> { + match self { + Self::PowerCobble { cobble } => Some( + cobble + .cardinal() + .into_iter() + .chain([cobble, cobble.up()]) + .collect(), + ), + Self::ConnectPosition { .. } | Self::PlaceRedstone { .. } => None, + } + } +} + +fn route_bounds_for_mode( + mode: BoundSearchMode, + world: &World3D, + terminal_node: &PlacedNode, +) -> Vec { + match mode { + BoundSearchMode::Propagation => terminal_node.propagation_bound(Some(world)), + BoundSearchMode::Nearby => nearby_route_bounds(world, terminal_node.position), + } +} + +fn nearby_route_bounds(world: &World3D, position: Position) -> Vec { + let mut result = Vec::new(); + for next in nearby_route_positions(world, position) { + result.push(PlaceBound(PropagateType::Soft, next, next.diff(position))); + } + result +} + +fn nearby_route_positions(world: &World3D, position: Position) -> Vec { + let mut result = Vec::new(); + let horizontal = [ + (position.0.checked_add(1), Some(position.1)), + (Some(position.0), position.1.checked_add(1)), + (position.0.checked_sub(1), Some(position.1)), + (Some(position.0), position.1.checked_sub(1)), + ]; + + for (next_x, next_y) in horizontal { + let (Some(next_x), Some(next_y)) = (next_x, next_y) else { + continue; + }; + for next_z in [position.2, position.2 + 1] { + let next = Position(next_x, next_y, next_z); + if world.size.bound_on(next) { + result.push(next); + } + } + if let Some(next_z) = position.2.checked_sub(1) { + let next = Position(next_x, next_y, next_z); + if world.size.bound_on(next) { + result.push(next); + } + } + } + result +} + +fn added_route_blocks(before: &World3D, after: &World3D) -> Vec<(Position, Block)> { + after + .iter_block() + .into_iter() + .filter(|(position, _)| before[*position].kind.is_air()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::module::{GraphModule, GraphModulePortType, GraphModuleVariable}; + use crate::transform::place_and_route::global_pnr::ir::{ + LayoutCandidate, PhysicalPort, PhysicalPortDirection, + }; + use crate::transform::place_and_route::global_pnr::placer::{ + place_candidates_on_shelves, GlobalPlacementConfig, + }; + use crate::world::block::{BlockKind, Direction}; + + fn candidate( + module_name: &str, + block_position: Position, + port_name: &str, + direction: PhysicalPortDirection, + ) -> LayoutCandidate { + let mut world = World3D::new(DimSize(2, 1, 2)); + world[block_position.down().unwrap()] = cobble_block(); + world[block_position] = redstone_block(); + world.initialize_redstone_states(); + LayoutCandidate::from_world( + module_name.to_owned(), + world, + vec![PhysicalPort { + name: port_name.to_owned(), + direction, + position: block_position, + }], + ) + .unwrap() + } + + fn route_test_world(source: Position, sink: Position) -> World3D { + let mut world = World3D::new(DimSize(8, 4, 3)); + world[source.down().unwrap()] = cobble_block(); + world[source] = redstone_block(); + world[sink.down().unwrap()] = cobble_block(); + world[sink] = redstone_block(); + world.initialize_redstone_states(); + world + } + + fn route_test_world_with_size(source: Position, sink: Position, size: DimSize) -> World3D { + let mut world = World3D::new(size); + world[source.down().unwrap()] = cobble_block(); + world[source] = redstone_block(); + world[sink.down().unwrap()] = cobble_block(); + world[sink] = redstone_block(); + world.initialize_redstone_states(); + world + } + + fn cobble_block() -> Block { + Block { + kind: BlockKind::Cobble { + on_count: 0, + on_base_count: 0, + }, + direction: Direction::None, + } + } + + fn redstone_block() -> Block { + Block { + kind: BlockKind::Redstone { + on_count: 0, + state: 0, + strength: 0, + }, + direction: Direction::None, + } + } + + #[test] + fn route_point_to_point_places_support_cobble_under_redstone() { + let source = Position(0, 1, 1); + let sink = Position(3, 1, 1); + let world = route_test_world(source, sink); + let (route, _) = route_point_to_point(&world, source, sink).unwrap(); + + assert!(route + .blocks + .iter() + .any(|(position, block)| *position == Position(1, 1, 0) && block.kind.is_cobble())); + assert!(route + .blocks + .iter() + .any(|(position, block)| *position == Position(1, 1, 1) && block.kind.is_redstone())); + } + + #[test] + fn route_point_to_point_avoids_blocked_route_position() { + let source = Position(0, 1, 1); + let sink = Position(3, 1, 1); + let mut world = route_test_world(source, sink); + world[Position(1, 1, 1)] = cobble_block(); + let (route, _) = route_point_to_point(&world, source, sink).unwrap(); + + assert!(!route + .blocks + .iter() + .any(|(position, _)| *position == Position(1, 1, 1))); + } + + #[test] + fn route_point_to_point_refreshes_long_redstone_with_repeater() { + let source = Position(0, 1, 1); + let sink = Position(22, 1, 1); + let world = route_test_world_with_size(source, sink, DimSize(26, 4, 3)); + let (route, _) = route_point_to_point(&world, source, sink).unwrap(); + + assert!(route + .blocks + .iter() + .any(|(_, block)| block.kind.is_repeater())); + } + + #[test] + fn route_module_variables_connects_placed_candidate_ports() -> eyre::Result<()> { + let module = GraphModule { + vars: vec![GraphModuleVariable { + var_type: GraphModulePortType::InputNet, + source: ("left".to_owned(), "out".to_owned()), + target: ("right".to_owned(), "in".to_owned()), + }], + ..Default::default() + }; + let candidates = vec![ + candidate( + "left", + Position(0, 0, 1), + "out", + PhysicalPortDirection::Output, + ), + candidate( + "right", + Position(0, 0, 1), + "in", + PhysicalPortDirection::Input, + ), + ]; + let placed = place_candidates_on_shelves( + &candidates, + &GlobalPlacementConfig { + spacing: 3, + shelf_width: 16, + }, + ); + + let routes = route_module_variables(&module, &candidates, &placed)?; + + assert_eq!(routes.len(), 1); + assert!(!routes[0].blocks.is_empty()); + Ok(()) + } +} diff --git a/src/transform/place_and_route/local_placer/config.rs b/src/transform/place_and_route/local_placer/config.rs index 7a1c43a..bd07445 100644 --- a/src/transform/place_and_route/local_placer/config.rs +++ b/src/transform/place_and_route/local_placer/config.rs @@ -1,4 +1,8 @@ +use std::collections::HashMap; + +use crate::graph::GraphNodeId; use crate::transform::place_and_route::sampling::SamplingPolicy; +use crate::world::position::Position; #[derive(Copy, Clone)] pub struct LocalPlacerConfig { @@ -53,6 +57,53 @@ pub enum InputPlacementStrategy { Anywhere, } +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct LocalPlacerInputConstraints { + positions_by_node_id: HashMap>, + positions_by_input_name: HashMap>, +} + +impl LocalPlacerInputConstraints { + pub fn new() -> Self { + Self::default() + } + + pub fn with_node_positions( + mut self, + node_id: GraphNodeId, + positions: impl IntoIterator, + ) -> Self { + self.positions_by_node_id + .insert(node_id, positions.into_iter().collect()); + self + } + + pub fn with_input_positions( + mut self, + input_name: impl Into, + positions: impl IntoIterator, + ) -> Self { + self.positions_by_input_name + .insert(input_name.into(), positions.into_iter().collect()); + self + } + + pub(super) fn positions_for( + &self, + node_id: GraphNodeId, + input_name: &str, + ) -> Option> { + self.positions_by_node_id + .get(&node_id) + .or_else(|| self.positions_by_input_name.get(input_name)) + .cloned() + } + + pub fn positions_for_input_name(&self, input_name: &str) -> Option> { + self.positions_by_input_name.get(input_name).cloned() + } +} + #[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] pub enum TorchPlacementStrategy { /// Torch를 입력과 직접 연결되는 위치에만 배치한다. diff --git a/src/transform/place_and_route/local_placer/debug.rs b/src/transform/place_and_route/local_placer/debug.rs index dcfa2bf..2f0b825 100644 --- a/src/transform/place_and_route/local_placer/debug.rs +++ b/src/transform/place_and_route/local_placer/debug.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use crate::graph::GraphNodeId; +pub use crate::transform::place_and_route::detailed_router::RouteRejectReason; use crate::world::position::Position; #[derive(Debug, Default)] @@ -67,18 +68,6 @@ pub struct StepDebug { pub route_debug: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum RouteRejectReason { - InitInputTopCobbleConflict, - InitOutputTopCobbleConflict, - OutOfBounds, - NoBottomForCobble, - CobbleConflict, - RedstoneConflict, - DisconnectedRoute, - ShortCircuit, -} - #[derive(Debug, Default, Clone)] pub struct RouteDebug { pub route_calls: usize, diff --git a/src/transform/place_and_route/local_placer/mod.rs b/src/transform/place_and_route/local_placer/mod.rs index 2fde7c2..8cac216 100644 --- a/src/transform/place_and_route/local_placer/mod.rs +++ b/src/transform/place_and_route/local_placer/mod.rs @@ -27,8 +27,8 @@ mod isolation; mod state; pub use config::{ - InputPlacementStrategy, LocalPlacerConfig, NotRouteStrategy, PlacementSamplingPolicy, - TorchPlacementStrategy, K_MAX_LOCAL_PLACE_NODE_COUNT, + InputPlacementStrategy, LocalPlacerConfig, LocalPlacerInputConstraints, NotRouteStrategy, + PlacementSamplingPolicy, TorchPlacementStrategy, K_MAX_LOCAL_PLACE_NODE_COUNT, }; pub use debug::{LocalPlacerDebug, RouteDebug, RouteDepthDebug, RouteRejectReason, StepDebug}; use isolation::RouteIsolation; @@ -140,15 +140,44 @@ impl LocalPlacer { self.generate_inner(dim, finish_step, None) } + pub fn generate_with_input_constraints( + &self, + dim: DimSize, + finish_step: Option, + input_constraints: &LocalPlacerInputConstraints, + ) -> Vec { + self.generate_queue(dim, finish_step, None, Some(input_constraints)) + .into_iter() + .map(|(world, _)| world) + .collect() + } + pub fn generate_with_outputs( &self, dim: DimSize, finish_step: Option, ) -> Vec { - self.generate_queue(dim, finish_step, None) + self.generate_queue(dim, finish_step, None, None) .into_iter() .map(|(world, state)| PlacedWorld { world, + inputs: self.input_endpoints(&state), + outputs: self.output_endpoints(&state), + }) + .collect() + } + + pub fn generate_with_outputs_and_input_constraints( + &self, + dim: DimSize, + finish_step: Option, + input_constraints: &LocalPlacerInputConstraints, + ) -> Vec { + self.generate_queue(dim, finish_step, None, Some(input_constraints)) + .into_iter() + .map(|(world, state)| PlacedWorld { + world, + inputs: self.input_endpoints(&state), outputs: self.output_endpoints(&state), }) .collect() @@ -169,12 +198,26 @@ impl LocalPlacer { finish_step: Option, debug: Option<&mut LocalPlacerDebug>, ) -> Vec { - self.generate_queue(dim, finish_step, debug) + self.generate_queue(dim, finish_step, debug, None) .into_iter() .map(|(world, _)| world) .collect() } + fn input_endpoints(&self, state: &PlacementState) -> Vec { + self.graph + .nodes + .iter() + .filter_map(|node| match &node.kind { + GraphNodeKind::Input(name) => state + .node_position(node.id) + .map(|position| OutputEndpoint::new(name.clone(), position)), + _ => None, + }) + .sorted_by(|a, b| a.name.cmp(&b.name)) + .collect() + } + fn output_endpoints(&self, state: &PlacementState) -> Vec { self.graph .nodes @@ -182,6 +225,11 @@ impl LocalPlacer { .filter_map(|node| match &node.kind { GraphNodeKind::Output(name) => state .node_position(node.id) + .or_else(|| { + node.inputs + .first() + .and_then(|source_id| state.node_position(*source_id)) + }) .map(|position| OutputEndpoint::new(name.clone(), position)), _ => None, }) @@ -194,24 +242,40 @@ impl LocalPlacer { dim: DimSize, finish_step: Option, debug: Option<&mut LocalPlacerDebug>, + input_constraints: Option<&LocalPlacerInputConstraints>, ) -> PlacerQueue { let mut queue = PlacerQueue::new(); queue.push((World3D::new(dim), Default::default())); - self.generate_queue_from(queue, finish_step, debug) + self.generate_queue_from_with_input_constraints( + queue, + finish_step, + debug, + input_constraints, + ) } fn generate_queue_from( + &self, + queue: PlacerQueue, + finish_step: Option, + debug: Option<&mut LocalPlacerDebug>, + ) -> PlacerQueue { + self.generate_queue_from_with_input_constraints(queue, finish_step, debug, None) + } + + fn generate_queue_from_with_input_constraints( &self, mut queue: PlacerQueue, finish_step: Option, mut debug: Option<&mut LocalPlacerDebug>, + input_constraints: Option<&LocalPlacerInputConstraints>, ) -> PlacerQueue { tracing::info!("generate starts"); let mut step = 0; while step < self.visit_orders.len() && Some(step) != finish_step { let prev_len = queue.len(); - let result = self.do_step(step, queue); + let result = self.do_step(step, queue, input_constraints); let next_len = result.queue.len(); let compacted = self.compact_queue_after_step(step, result.queue); @@ -234,7 +298,12 @@ impl LocalPlacer { queue } - fn do_step(&self, step: usize, queue: PlacerQueue) -> StepResult { + fn do_step( + &self, + step: usize, + queue: PlacerQueue, + input_constraints: Option<&LocalPlacerInputConstraints>, + ) -> StepResult { let node = self.graph.find_node_by_id(self.visit_orders[step]).unwrap(); tracing::info!("[{}/{}] {node}", step + 1, self.visit_orders.len()); @@ -254,7 +323,8 @@ impl LocalPlacer { .panic_fuse() .progress_with_style(progress_style(step + 1, self.visit_orders.len())) .map(|(world, state)| { - let generation = self.generate_place_and_route(node, world, &state); + let generation = + self.generate_place_and_route(node, world, &state, input_constraints); (generation.items, generation.route_debug) }) .collect::>(); @@ -294,18 +364,28 @@ impl LocalPlacer { node: &GraphNode, world: World3D, state: &PlacementState, + input_constraints: Option<&LocalPlacerInputConstraints>, ) -> PlacementGeneration { let mut route_debug = None; let items = match node.kind { - GraphNodeKind::Input(_) => { + GraphNodeKind::Input(ref input_name) => { if let Some(position) = state.node_position(node.id) { let mut state = state.clone(); state.set_signal_footprint(node.id, [position]); vec![(world, state)] } else { + let constrained_positions = input_constraints + .and_then(|constraints| constraints.positions_for(node.id, input_name)); input_node_kind() .into_iter() - .flat_map(|kind| generate_inputs(&self.config, &world, kind)) + .flat_map(|kind| { + generate_inputs( + &self.config, + &world, + kind, + constrained_positions.as_deref(), + ) + }) .map(|(world, position)| { let mut state = state.clone(); state.set_node_position(node.id, position); @@ -483,6 +563,9 @@ impl LocalPlacer { self.config.materialize_outputs || !matches!(node.kind, GraphNodeKind::Output(_)) }) .flat_map(|node| node.inputs.iter().copied()) + .chain(self.graph.nodes.iter().filter_map(|node| { + matches!(node.kind, GraphNodeKind::Input(_)).then_some(node.id) + })) .chain(self.graph.externally_observable_output_source_ids()) .chain( self.config diff --git a/src/transform/place_and_route/local_placer/routing.rs b/src/transform/place_and_route/local_placer/routing.rs index 61363e3..a702428 100644 --- a/src/transform/place_and_route/local_placer/routing.rs +++ b/src/transform/place_and_route/local_placer/routing.rs @@ -1,4 +1,5 @@ use super::*; +use crate::transform::place_and_route::detailed_router; const OR_ROUTE_STEP_SAMPLE_SCOPE: u64 = 3; const NOT_ROUTE_STEP_SAMPLE_SCOPE: u64 = 5; @@ -23,23 +24,13 @@ pub(super) fn not_node_kind() -> Vec { vec![BlockKind::Torch { is_on: false }] } -pub(super) fn place_node(world: &mut World3D, node: PlacedNode) { - if world[node.position] == node.block { - // TODO: Relax for no-op - assert!(world[node.position].kind.is_cobble()); - return; - } - - world[node.position] = node.block; - if node.block.kind.is_redstone() { - world.update_redstone_states(node.position); - } -} +pub(super) use detailed_router::place_node; pub(super) fn generate_inputs( config: &LocalPlacerConfig, world: &World3D, kind: BlockKind, + constrained_positions: Option<&[Position]>, ) -> Vec<(World3D, Position)> { let mut input_strategy = Direction::iter_direction_without_top() .map(|direction| Block { kind, direction }) @@ -48,16 +39,25 @@ pub(super) fn generate_inputs( input_strategy = input_strategy.into_iter().take(1).collect(); } - let place_strategy = match config.input_placement_strategy { - InputPlacementStrategy::Boundary => iproduct!(0..1, 0..world.size.1, 0..world.size.2) - .chain(iproduct!(0..world.size.0, 0..1, 0..world.size.2)) - .map(|(x, y, z)| Position(x, y, z)) + let place_strategy = if let Some(positions) = constrained_positions { + positions + .iter() + .copied() + .filter(|position| world.size.bound_on(*position)) .unique() - .collect_vec(), - InputPlacementStrategy::Anywhere => { - iproduct!(0..world.size.0, 0..world.size.1, 0..world.size.2) + .collect_vec() + } else { + match config.input_placement_strategy { + InputPlacementStrategy::Boundary => iproduct!(0..1, 0..world.size.1, 0..world.size.2) + .chain(iproduct!(0..world.size.0, 0..1, 0..world.size.2)) .map(|(x, y, z)| Position(x, y, z)) - .collect_vec() + .unique() + .collect_vec(), + InputPlacementStrategy::Anywhere => { + iproduct!(0..world.size.0, 0..world.size.1, 0..world.size.2) + .map(|(x, y, z)| Position(x, y, z)) + .collect_vec() + } } }; @@ -744,31 +744,7 @@ pub(super) fn try_generate_cobble_node( cobble_pos: Position, except: &[Position], ) -> Option { - if cobble_would_stack_above_side_torch_support(world, cobble_pos) { - return None; - } - let cobble_node = PlacedNode::new_cobble(cobble_pos); - if !cobble_node.has_conflict(world, &except.iter().copied().collect()) { - Some(cobble_node) - } else { - None - } -} - -pub(super) fn cobble_would_stack_above_side_torch_support( - world: &World3D, - cobble_pos: Position, -) -> bool { - let Some(below) = cobble_pos.down() else { - return false; - }; - world.size.bound_on(below) - && world[below].kind.is_cobble() - && below.cardinal().into_iter().any(|position| { - world.size.bound_on(position) - && matches!(world[position].kind, BlockKind::Torch { .. }) - && world[position].direction == position.diff(below) - }) + detailed_router::try_generate_cobble_node(world, cobble_pos, except) } fn place_redstone_for_goal( @@ -793,7 +769,7 @@ pub(super) fn place_redstone_with_cobble( prev: Position, to: Position, ) -> PlaceRedstoneResult { - place_redstone_with_cobble_and_allowed_shorts(world, bound, prev, to, None) + detailed_router::place_redstone_with_cobble(world, bound, prev, to) } fn place_redstone_with_cobble_and_allowed_shorts( @@ -803,62 +779,14 @@ fn place_redstone_with_cobble_and_allowed_shorts( to: Position, allowed_shorts: Option<&HashSet>, ) -> PlaceRedstoneResult { - let Some(cobble_pos) = bound.position().walk(Direction::Bottom) else { - return PlaceRedstoneResult::Rejected(RouteRejectReason::NoBottomForCobble); - }; // 첫 번째 step에서 torch 위쪽에 cobble + redstone을 놓는 경우 예외 처리 - let cobble_except = (world[prev].kind.is_torch()) - .then_some(vec![cobble_pos, prev]) - .unwrap_or_default(); - let Some(cobble_node) = try_generate_cobble_node(world, cobble_pos, &cobble_except) else { - return PlaceRedstoneResult::Rejected(RouteRejectReason::CobbleConflict); - }; - let mut new_world = world.clone(); - place_node(&mut new_world, cobble_node); - - let bound_pos = bound.position(); - let Some(bound_back_pos) = bound_pos.walk(bound.direction()) else { - return PlaceRedstoneResult::Rejected(RouteRejectReason::RedstoneConflict); - }; - let redstone_node = PlacedNode::new_redstone(bound_pos); - let mut except = [prev, bound_back_pos, bound_pos, to, to.up()] - .into_iter() - .collect::>(); - if let Some(allowed_shorts) = allowed_shorts { - except.extend(allowed_shorts.iter().copied()); - } - let mut short_except = [prev, bound_pos, to, to.up()] - .into_iter() - .collect::>(); - if let Some(allowed_shorts) = allowed_shorts { - short_except.extend(allowed_shorts.iter().copied()); - } - if redstone_node.has_conflict(&new_world, &except) { - return PlaceRedstoneResult::Rejected(RouteRejectReason::RedstoneConflict); - } - if redstone_node.has_short(world, &short_except) { - return PlaceRedstoneResult::Rejected(RouteRejectReason::ShortCircuit); - } - place_node(&mut new_world, redstone_node); - new_world.update_redstone_states(prev); - if !target_powers_redstone(&new_world, prev, redstone_node.position) { - return PlaceRedstoneResult::Rejected(RouteRejectReason::DisconnectedRoute); - } - if redstone_node.has_short(&new_world, &short_except) { - return PlaceRedstoneResult::Rejected(RouteRejectReason::ShortCircuit); - } - if let BlockKind::Torch { .. } = world[prev].kind { - if let Some(source_cobble) = prev.walk(world[prev].direction) { - if redstone_powers_cobble(&new_world, redstone_node.position, source_cobble) { - return PlaceRedstoneResult::Rejected(RouteRejectReason::ShortCircuit); - } - } - } - - PlaceRedstoneResult::Placed(new_world, redstone_node) + detailed_router::place_redstone_with_cobble_and_allowed_shorts( + world, + bound, + prev, + to, + allowed_shorts, + ) } -pub(super) enum PlaceRedstoneResult { - Placed(World3D, PlacedNode), - Rejected(RouteRejectReason), -} +pub(super) type PlaceRedstoneResult = detailed_router::PlaceRedstoneResult; diff --git a/src/transform/place_and_route/local_placer/tests.rs b/src/transform/place_and_route/local_placer/tests.rs index f9a69a0..d52ae89 100644 --- a/src/transform/place_and_route/local_placer/tests.rs +++ b/src/transform/place_and_route/local_placer/tests.rs @@ -49,7 +49,7 @@ fn config(max_route_step: usize) -> LocalPlacerConfig { #[test] fn generate_inputs_uses_greedy_boundary_switch_placements() { let world = World3D::new(DimSize(3, 3, 2)); - let generated = generate_inputs(&config(1), &world, BlockKind::Switch { is_on: false }); + let generated = generate_inputs(&config(1), &world, BlockKind::Switch { is_on: false }, None); assert_eq!(generated.len(), 10); for (world, position) in generated { @@ -65,7 +65,7 @@ fn generate_inputs_can_search_anywhere() { let mut config = config(1); config.input_placement_strategy = InputPlacementStrategy::Anywhere; - let generated = generate_inputs(&config, &world, BlockKind::Switch { is_on: false }); + let generated = generate_inputs(&config, &world, BlockKind::Switch { is_on: false }, None); assert_eq!(generated.len(), 18); } @@ -76,11 +76,48 @@ fn generate_inputs_limits_candidates_per_world() { let mut config = config(1); config.input_candidate_limit = Some(4); - let generated = generate_inputs(&config, &world, BlockKind::Switch { is_on: false }); + let generated = generate_inputs(&config, &world, BlockKind::Switch { is_on: false }, None); assert_eq!(generated.len(), 4); } +#[test] +fn generate_inputs_uses_explicit_position_region() { + let world = World3D::new(DimSize(6, 6, 4)); + let positions = [Position(3, 3, 1), Position(4, 3, 1)]; + + let generated = generate_inputs( + &config(1), + &world, + BlockKind::Switch { is_on: false }, + Some(&positions), + ); + + assert_eq!(generated.len(), 2); + assert_eq!( + generated + .iter() + .map(|(_, position)| *position) + .collect_vec(), + positions + ); +} + +#[test] +fn local_placer_limits_input_search_to_named_constraints() -> eyre::Result<()> { + let graph = LogicGraph::from_stmt("a", "out")?; + let placer = LocalPlacer::new(graph, config(1))?; + let input_position = Position(3, 3, 1); + let constraints = + LocalPlacerInputConstraints::new().with_input_positions("a", [input_position]); + + let generated = placer.generate_with_input_constraints(DimSize(6, 6, 4), None, &constraints); + + assert_eq!(generated.len(), 1); + assert!(generated[0][input_position].kind.is_switch()); + Ok(()) +} + #[test] fn local_placer_accepts_chained_or_after_buffer_insertion() -> eyre::Result<()> { let mut graph = LogicGraph::from_stmt("a|b", "x")?; @@ -267,7 +304,7 @@ fn output_step_routes_visible_redstone_endpoint_from_or_source() -> eyre::Result place_node(&mut world, PlacedNode::new_redstone(source)); let state = [(or_id, source)].into_iter().collect(); - let result = placer.do_step(output_step, vec![(world, state)]); + let result = placer.do_step(output_step, vec![(world, state)], None); assert!(!result.queue.is_empty()); assert!(result.queue.iter().any(|(world, state)| { @@ -312,7 +349,7 @@ fn output_step_routes_visible_redstone_endpoint_from_torch_source() -> eyre::Res ); let state = [(not_id, source)].into_iter().collect(); - let result = placer.do_step(output_step, vec![(world, state)]); + let result = placer.do_step(output_step, vec![(world, state)], None); assert!(!result.queue.is_empty()); assert!(result.queue.iter().any(|(world, state)| { @@ -341,6 +378,23 @@ fn generate_with_outputs_reports_materialized_output_positions() -> eyre::Result Ok(()) } +#[test] +fn generate_with_outputs_reports_input_positions_after_queue_compaction() -> eyre::Result<()> { + let graph = LogicGraph::from_stmt("~a", "out")?.prepare_place()?; + let placer = LocalPlacer::new(graph, config(1))?; + + let placed = placer.generate_with_outputs(DimSize(5, 5, 3), None); + + assert!(!placed.is_empty()); + assert!(placed.iter().any(|placed| { + placed + .inputs + .iter() + .any(|endpoint| endpoint.name == "a" && placed.world[endpoint.position()].kind.is_switch()) + })); + Ok(()) +} + #[test] fn future_join_cost_weights_pairs_with_remaining_fanout() { let mut graph = Graph { diff --git a/src/transform/place_and_route/mod.rs b/src/transform/place_and_route/mod.rs index b3ffe63..fceafc1 100644 --- a/src/transform/place_and_route/mod.rs +++ b/src/transform/place_and_route/mod.rs @@ -1,4 +1,6 @@ +pub mod detailed_router; pub mod estimate; +pub mod global_pnr; pub mod local_placer; pub mod place_bound; pub mod placed_node; diff --git a/src/world/simulator.rs b/src/world/simulator.rs index da5cdd2..d37dca9 100644 --- a/src/world/simulator.rs +++ b/src/world/simulator.rs @@ -11,6 +11,10 @@ const DEFAULT_TRACE_LIMIT: usize = 0; // not exact game ticks or redstone ticks. const TORCH_BURNOUT_WINDOW_CYCLES: usize = 60; const TORCH_BURNOUT_TOGGLE_LIMIT: usize = 8; +// Torch support changes are evaluated after a small simulator delay, then +// rechecked at application time so short transient power does not force a +// stale torch state transition. +const TORCH_UPDATE_DELAY_CYCLES: usize = 1; #[derive(Clone, Debug, PartialEq, Eq, Hash)] enum EventType { @@ -193,7 +197,6 @@ impl Simulator { snapshots: sim.snapshots, }); } - sim.clear_transient_torch_burnout(); Ok(sim) } @@ -216,7 +219,6 @@ impl Simulator { sim.fill_event_id(); sim.run_inner(limits)?; - sim.clear_transient_torch_burnout(); Ok(sim) } @@ -339,7 +341,6 @@ impl Simulator { self.enqueue_torch_reevaluations(); self.fill_event_id(); self.run_inner(limits)?; - self.clear_transient_torch_burnout(); } Ok(()) @@ -362,11 +363,6 @@ impl Simulator { self.snapshots.clear(); } - fn clear_transient_torch_burnout(&mut self) { - self.torch_toggle_cycles.clear(); - self.burned_out_torches.clear(); - } - pub fn run(&mut self) -> eyre::Result { self.run_inner(SimulationLimits::cycles(None)) } @@ -419,6 +415,13 @@ impl Simulator { self.queue.back_mut().unwrap().push_back(event); } + fn schedule_event(&mut self, delay_cycles: usize, event: Event) { + while self.queue.len() <= delay_cycles { + self.queue.push_back(VecDeque::new()); + } + self.queue[delay_cycles].push_back(event); + } + fn normalize_torches_on(&mut self) { for pos in self.world.iter_pos() { if matches!(self.world[pos].kind, BlockKind::Torch { .. }) { @@ -505,10 +508,9 @@ impl Simulator { }) .collect::>(); - if events.is_empty() { - return; + for event in events { + self.schedule_event(TORCH_UPDATE_DELAY_CYCLES, event); } - self.queue.push_back(events.into()); } fn redstone_propagate_targets(&self, pos: Position, state: usize) -> Vec { @@ -1243,15 +1245,13 @@ impl Simulator { eyre::bail!("unreachable"); }; - let next_is_on = !event.event_type.is_on(); + let support_is_powered = self.cobble_power_counts(support_position).0 > 0; + let next_is_on = !support_is_powered; if *is_on == next_is_on { return Ok(()); } if next_is_on && self.burned_out_torches.contains(&event.target_position) { - if self.torch_is_still_burned_out(event.target_position) { - return Ok(()); - } - self.burned_out_torches.remove(&event.target_position); + return Ok(()); } let burned_out = self.record_torch_toggle(event.target_position); if burned_out { @@ -1312,13 +1312,6 @@ impl Simulator { history.len() >= TORCH_BURNOUT_TOGGLE_LIMIT } - fn torch_is_still_burned_out(&mut self, position: Position) -> bool { - self.prune_torch_toggle_history(position); - self.torch_toggle_cycles - .get(&position) - .is_some_and(|history| history.len() >= TORCH_BURNOUT_TOGGLE_LIMIT) - } - fn prune_torch_toggle_history(&mut self, position: Position) { let Some(history) = self.torch_toggle_cycles.get_mut(&position) else { return; @@ -1751,6 +1744,108 @@ mod test { Ok(()) } + #[test] + fn unittest_simulator_torch_reevaluation_uses_current_support_power() -> eyre::Result<()> { + let torch = Position(1, 1, 1); + let support = Position(1, 1, 0); + let mock_world = World { + size: DimSize(3, 3, 2), + blocks: vec![ + ( + support, + Block { + kind: BlockKind::Cobble { + on_count: 0, + on_base_count: 0, + }, + direction: Direction::None, + }, + ), + ( + torch, + Block { + kind: BlockKind::Torch { is_on: true }, + direction: Direction::Bottom, + }, + ), + ], + }; + let mut sim = Simulator::new(&mock_world, DEFAULT_TRACE_LIMIT); + sim.queue.push_back(VecDeque::new()); + let mut block = sim.world[torch]; + + sim.propgate_torch_event( + &mut block, + &Event { + id: None, + from_id: None, + event_type: EventType::SoftOn, + target_position: torch, + direction: Direction::Bottom, + }, + )?; + + assert!( + matches!(block.kind, BlockKind::Torch { is_on: true }), + "stale powered-support reevaluation should not turn the torch off" + ); + + Ok(()) + } + + #[test] + fn unittest_simulator_burned_out_torch_does_not_recover_during_session() -> eyre::Result<()> { + let torch = Position(1, 1, 1); + let support = Position(1, 1, 0); + let mock_world = World { + size: DimSize(3, 3, 2), + blocks: vec![ + ( + support, + Block { + kind: BlockKind::Cobble { + on_count: 0, + on_base_count: 0, + }, + direction: Direction::None, + }, + ), + ( + torch, + Block { + kind: BlockKind::Torch { is_on: false }, + direction: Direction::Bottom, + }, + ), + ], + }; + let mut sim = Simulator::new(&mock_world, DEFAULT_TRACE_LIMIT); + sim.queue.push_back(VecDeque::new()); + sim.cycle = TORCH_BURNOUT_WINDOW_CYCLES * 2; + sim.burned_out_torches.insert(torch); + sim.torch_toggle_cycles + .insert(torch, VecDeque::from([0, 1, 2, 3, 4, 5, 6, 7])); + let mut block = sim.world[torch]; + + sim.propgate_torch_event( + &mut block, + &Event { + id: None, + from_id: None, + event_type: EventType::SoftOff, + target_position: torch, + direction: Direction::Bottom, + }, + )?; + + assert!( + matches!(block.kind, BlockKind::Torch { is_on: false }), + "burnout is a simulator-session stabilization state and should not recover by age" + ); + + Ok(()) + } + #[test] fn unittest_simulator_redstone_event_ignores_out_of_bounds_neighbors() -> eyre::Result<()> { let target = Position(1, 0, 0); diff --git a/test/d-flip-flop-global-smoke.nbt b/test/d-flip-flop-global-smoke.nbt new file mode 100644 index 0000000..104158c Binary files /dev/null and b/test/d-flip-flop-global-smoke.nbt differ