From 3c10955548d8675c2aafbfc58a1c728b5790591f Mon Sep 17 00:00:00 2001 From: Shaopeng Luan Date: Sat, 4 Apr 2026 12:13:11 +1100 Subject: [PATCH 1/7] first approach --- Cargo.toml | 11 + crates/bevy_ecs/macros/src/component.rs | 122 +++++ crates/bevy_ecs/macros/src/lib.rs | 2 +- crates/bevy_ecs/src/archetype.rs | 205 +++++++- crates/bevy_ecs/src/bundle/insert.rs | 24 +- crates/bevy_ecs/src/bundle/remove.rs | 11 +- crates/bevy_ecs/src/bundle/spawner.rs | 12 +- crates/bevy_ecs/src/component/constraint.rs | 475 ++++++++++++++++++ crates/bevy_ecs/src/component/info.rs | 13 +- crates/bevy_ecs/src/component/mod.rs | 9 + crates/bevy_ecs/src/component/register.rs | 25 +- .../src/world/entity_access/world_mut.rs | 24 +- crates/bevy_ecs/src/world/mod.rs | 176 ++++--- crates/bevy_ecs/src/world/spawn_batch.rs | 73 ++- examples/ecs/component_constraints.rs | 129 +++++ 15 files changed, 1166 insertions(+), 145 deletions(-) create mode 100644 crates/bevy_ecs/src/component/constraint.rs create mode 100644 examples/ecs/component_constraints.rs diff --git a/Cargo.toml b/Cargo.toml index ee3a5324cc9c1..8aff1d9d53a25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2324,6 +2324,17 @@ description = "Store arbitrary systems in components and run them on demand" category = "ECS (Entity Component System)" wasm = true +[[example]] +name = "component_constraints" +path = "examples/ecs/component_constraints.rs" +doc-scrape-examples = true + +[package.metadata.example.component_constraints] +name = "Component Constraints" +description = "Declare constraints on component relationships using require, forbid, and, or, not, and only" +category = "ECS (Entity Component System)" +wasm = false + [[example]] name = "component_hooks" path = "examples/ecs/component_hooks.rs" diff --git a/crates/bevy_ecs/macros/src/component.rs b/crates/bevy_ecs/macros/src/component.rs index ba4907b25d568..5341c2b33cf78 100644 --- a/crates/bevy_ecs/macros/src/component.rs +++ b/crates/bevy_ecs/macros/src/component.rs @@ -324,6 +324,35 @@ pub fn derive_component(input: TokenStream) -> TokenStream { quote! {None} }; + let has_constraint = attrs.constraint_expr.is_some() || attrs.constraint_only.is_some(); + let register_constraint = has_constraint.then(|| { + let dnf_expr = attrs.constraint_expr.as_ref().map(|ast| { + let transformed = constraint_to_tokens(ast, &bevy_ecs_path); + quote! { Some(#transformed) } + }).unwrap_or_else(|| quote! { None }); + + let only_expr = attrs.constraint_only.as_ref().map(|paths| { + quote! {{ + let ids: &[#bevy_ecs_path::component::ComponentId] = &[ + #( _registrator.register_component::<#paths>(), )* + ]; + Some(ids.to_vec()) + }} + }).unwrap_or_else(|| quote! { None }); + + quote! { + fn register_constraint( + _registrator: &mut #bevy_ecs_path::component::ComponentsRegistrator, + ) -> Option<#bevy_ecs_path::component::ComponentConstraint> { + Some(#bevy_ecs_path::component::ComponentConstraint::from_expr( + #dnf_expr, + #only_expr, + )) + } + } + }); + + // This puts `register_required` before `register_recursive_requires` to ensure that the constructors of _all_ top // level components are initialized first, giving them precedence over recursively defined constructors for the same component type TokenStream::from(quote! { @@ -353,6 +382,8 @@ pub fn derive_component(input: TokenStream) -> TokenStream { fn relationship_accessor() -> Option<#bevy_ecs_path::relationship::ComponentRelationshipAccessor> { #relationship_accessor } + + #register_constraint } #relationship @@ -361,6 +392,29 @@ pub fn derive_component(input: TokenStream) -> TokenStream { }) } +fn constraint_to_tokens(ast: &ComponentConstraintAst, bevy_ecs_path: &Path) -> TokenStream2 { + match ast { + ComponentConstraintAst::Require(path) => { + quote! { #bevy_ecs_path::component::require(_registrator.register_component::<#path>()) } + }, + ComponentConstraintAst::Forbid(path) => { + quote! { #bevy_ecs_path::component::forbid(_registrator.register_component::<#path>()) } + }, + ComponentConstraintAst::Not(inner) => { + let inner = constraint_to_tokens(inner, bevy_ecs_path); + quote! { #bevy_ecs_path::component::not(#inner) } + }, + ComponentConstraintAst::And(asts) => { + let items: Vec = asts.iter().map(|i| constraint_to_tokens(i, bevy_ecs_path)).collect(); + quote! { #bevy_ecs_path::component::and([#(#items),*]) } + }, + ComponentConstraintAst::Or(asts) => { + let items: Vec = asts.iter().map(|i| constraint_to_tokens(i, bevy_ecs_path)).collect(); + quote! { #bevy_ecs_path::component::or([#(#items),*]) } + }, + } +} + const ENTITIES: &str = "entities"; pub(crate) fn map_entities( @@ -457,6 +511,7 @@ pub(crate) fn map_entities( pub const COMPONENT: &str = "component"; pub const STORAGE: &str = "storage"; pub const REQUIRE: &str = "require"; +pub const CONSTRAINT: &str = "constraint"; pub const RELATIONSHIP: &str = "relationship"; pub const RELATIONSHIP_TARGET: &str = "relationship_target"; @@ -593,6 +648,8 @@ struct Attrs { immutable: bool, clone_behavior: Option, map_entities: Option, + constraint_expr: Option, + constraint_only: Option> } #[derive(Clone, Copy)] @@ -616,6 +673,14 @@ struct RelationshipTarget { linked_spawn: bool, } +enum ComponentConstraintAst { + Require(Path), + Forbid(Path), + Not(Box), + And(Vec), + Or(Vec), +} + // values for `storage` attribute const TABLE: &str = "Table"; const SPARSE_SET: &str = "SparseSet"; @@ -634,6 +699,8 @@ fn parse_component_attr(ast: &DeriveInput) -> Result { immutable: false, clone_behavior: None, map_entities: None, + constraint_expr: None, + constraint_only: None, }; let mut require_paths = HashSet::new(); @@ -705,6 +772,20 @@ fn parse_component_attr(ast: &DeriveInput) -> Result { } else { attrs.requires = Some(punctuated); } + } else if attr.path().is_ident(CONSTRAINT) { + attr.parse_args_with(|input: syn::parse::ParseStream| { + let ident = input.fork().parse::()?; + if ident == "only" { + input.parse::()?; // consume "only" + let content; + parenthesized!(content in input); + let paths = Punctuated::::parse_terminated(&content)?; + attrs.constraint_only = Some(paths.into_iter().collect()); + } else { + attrs.constraint_expr = Some(input.parse::()?); + } + Ok(()) + })?; } else if attr.path().is_ident(RELATIONSHIP) { let relationship = attr.parse_args::()?; attrs.relationship = Some(relationship); @@ -878,6 +959,47 @@ impl Parse for RelationshipTarget { } } +impl Parse for ComponentConstraintAst { + fn parse(input: syn::parse::ParseStream) -> Result { + let ident: Ident = input.parse()?; + let content; + parenthesized!(content in input); + + match ident.to_string().as_str() { + "require" => { + let path: Path = content.parse()?; + Ok(ComponentConstraintAst::Require(path)) + } + "forbid" => { + let path: Path = content.parse()?; + Ok(ComponentConstraintAst::Forbid(path)) + } + "not" => { + let inner: ComponentConstraintAst = content.parse()?; + Ok(ComponentConstraintAst::Not(Box::new(inner))) + } + "and" => { + let items = Punctuated::::parse_terminated(&content)?; + Ok(ComponentConstraintAst::And(items.into_iter().collect())) + } + "or" => { + let items = Punctuated::::parse_terminated(&content)?; + Ok(ComponentConstraintAst::Or(items.into_iter().collect())) + } + "only" => { + Err(syn::Error::new( + ident.span(), + "`only` cannot be used inside expressions, use a separate #[constraint(only(...))] attribute", + )) + } + _ => Err(syn::Error::new( + ident.span(), + format!("unknown constraint: `{}`, expected one of: require, forbid, not, and, or, only", ident) + )) + } + } +} + fn derive_relationship( ast: &DeriveInput, attrs: &Attrs, diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index cfcb35883bc3c..65a7ad0baaa2a 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -713,7 +713,7 @@ pub fn derive_settings_group(input: TokenStream) -> TokenStream { /// ``` #[proc_macro_derive( Component, - attributes(component, require, relationship, relationship_target, entities) + attributes(component, require, constraint, relationship, relationship_target, entities) )] pub fn derive_component(input: TokenStream) -> TokenStream { component::derive_component(input) diff --git a/crates/bevy_ecs/src/archetype.rs b/crates/bevy_ecs/src/archetype.rs index 64ca9740567e6..d7e5209d22563 100644 --- a/crates/bevy_ecs/src/archetype.rs +++ b/crates/bevy_ecs/src/archetype.rs @@ -21,7 +21,10 @@ use crate::{ bundle::BundleId, - component::{ComponentId, Components, RequiredComponentConstructor, StorageType}, + component::{ + ComponentConstraint, ComponentId, Components, ComponentsConstraintError, + RequiredComponentConstructor, StorageType, + }, entity::{Entity, EntityLocation}, event::Event, observer::Observers, @@ -29,11 +32,17 @@ use crate::{ storage::{ImmutableSparseSet, SparseArray, SparseSet, TableId, TableRow}, }; use alloc::{boxed::Box, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, +}; use bevy_platform::collections::{hash_map::Entry, HashMap}; +use bevy_utils::prelude::DebugName; use core::{ hash::Hash, ops::{Index, IndexMut, RangeFrom}, }; +use fixedbitset::FixedBitSet; use nonmax::NonMaxU32; #[derive(Event)] @@ -796,15 +805,17 @@ impl Archetypes { by_components: Default::default(), by_component: Default::default(), }; - // SAFETY: Empty archetype has no components + // SAFETY: Empty archetype has no components, constraints always pass unsafe { - archetypes.get_id_or_insert( - &Components::default(), - &Observers::default(), - TableId::empty(), - Vec::new(), - Vec::new(), - ); + archetypes + .get_id_or_insert( + &Components::default(), + &Observers::default(), + TableId::empty(), + Vec::new(), + Vec::new(), + ) + .expect("Empty archetype should always be valid, THIS SHOULD NOT HAPPEN"); } archetypes } @@ -908,7 +919,7 @@ impl Archetypes { table_id: TableId, table_components: Vec, sparse_set_components: Vec, - ) -> (ArchetypeId, bool) { + ) -> Result<(ArchetypeId, bool), ComponentsConstraintError> { let archetype_identity = ArchetypeComponents { sparse_set_components: sparse_set_components.into_boxed_slice(), table_components: table_components.into_boxed_slice(), @@ -917,12 +928,15 @@ impl Archetypes { let archetypes = &mut self.archetypes; let component_index = &mut self.by_component; match self.by_components.entry(archetype_identity) { - Entry::Occupied(occupied) => (*occupied.get(), false), + Entry::Occupied(occupied) => Ok((*occupied.get(), false)), Entry::Vacant(vacant) => { let ArchetypeComponents { table_components, sparse_set_components, } = vacant.key(); + + Self::validate_constraints(components, table_components, sparse_set_components)?; + let id = ArchetypeId::new(archetypes.len()); archetypes.push(Archetype::new( components, @@ -934,9 +948,176 @@ impl Archetypes { sparse_set_components.iter().copied(), )); vacant.insert(id); - (id, true) + Ok((id, true)) + } + } + } + + /// Validates that all component constraints are satisfied for a proposed archetype. + fn validate_constraints( + components: &Components, + table_components: &[ComponentId], + sparse_set_components: &[ComponentId], + ) -> Result<(), ComponentsConstraintError> { + let all_iter = table_components.iter().chain(sparse_set_components.iter()); + let max_id = all_iter.clone().map(|c| c.index()).max().unwrap_or(0); + let mut archetype_bits = FixedBitSet::with_capacity(max_id + 1); + for c in all_iter.clone() { + archetype_bits.insert(c.index()); + } + for c in all_iter { + if let Some(info) = components.get_info(*c) + && let Some(constraint) = info.constraint() + { + let mut ok = true; + + // First check only fields + if let Some(only) = &constraint.only + && !archetype_bits.is_subset(only) + { + ok = false; + } + + // Second check requires fields + if ok + && let Some(dnf) = &constraint.dnf + && !dnf.satisfied_by(&archetype_bits) + { + ok = false; + } + + if !ok { + let err = Self::build_constraint_error(*c, &archetype_bits, constraint); + if cfg!(feature = "debug") { + Self::log_constraint_violation( + components, + &archetype_bits, + info.name(), + &err, + ); + } + return Err(err); + } + } + } + Ok(()) + } + + fn build_constraint_error( + component: ComponentId, + archetype_bits: &FixedBitSet, + constraint: &ComponentConstraint, + ) -> ComponentsConstraintError { + let mut missing = Vec::new(); + let mut conflicting = Vec::new(); + let mut disallowed = Vec::new(); + + if let Some(dnf) = &constraint.dnf { + for clause in dnf.clauses() { + let m = clause + .required + .ones() + .filter(|&idx| !archetype_bits.contains(idx)) + .map(ComponentId::new) + .collect::>(); + + let c = clause + .forbidden + .intersection(archetype_bits) + .map(ComponentId::new) + .collect::>(); + if !m.is_empty() { + missing.push(m); + } + if !c.is_empty() { + conflicting.push(c); + } + } + } + + if let Some(only) = &constraint.only { + disallowed = archetype_bits + .ones() + .filter(|&idx| !only.contains(idx)) + .map(ComponentId::new) + .collect(); + } + + ComponentsConstraintError { + disallowed, + component, + missing, + conflicting, + } + } + + fn log_constraint_violation( + components: &Components, + archetype_bits: &FixedBitSet, + violator_name: DebugName, + error: &ComponentsConstraintError, + ) { + let component_name = |id: ComponentId| -> String { + components + .get_info(id) + .map(|info| format!("{}", info.name())) + .unwrap_or_else(|| format!("ComponentId({})", id.index())) + }; + + let component_names = |ids: &Vec| -> Vec { + ids.iter().map(|id| component_name(*id)).collect() + }; + + // List all components in the proposed archetype. + let archetype_components: Vec = archetype_bits + .ones() + .map(|id| component_name(ComponentId::new(id))) + .collect(); + + let mut reasons = Vec::new(); + let disallowed = error + .disallowed + .iter() + .map(|id| component_name(*id)) + .collect::>(); + let missing = error + .missing + .iter() + .map(component_names) + .collect::>>(); + let conflicting = error + .conflicting + .iter() + .map(component_names) + .collect::>>(); + + if !disallowed.is_empty() { + reasons.push(format!( + "These components are disallowed (not in \"any\" field): {:?}", + disallowed + )); + } + if !missing.is_empty() { + reasons.push("Pick any:\n".to_string()); + for entry in missing { + reasons.push(format!(" - add [{}]\n", entry.join(", "))); } } + if !conflicting.is_empty() { + reasons.push("Remove any:\n".to_string()); + for entry in conflicting { + reasons.push(format!(" - remove [{}]\n", entry.join(", "))); + } + } + + log::warn!( + "\n\nConstraint violated for component \"{}\" (RESTRICT), rollback.\n\ + Proposed components: [{}]\n\n\ + {}", + violator_name, + archetype_components.join(", "), + reasons.join("\n"), + ); } /// Clears all entities from all archetypes. diff --git a/crates/bevy_ecs/src/bundle/insert.rs b/crates/bevy_ecs/src/bundle/insert.rs index d1b8200089314..fee2aea0761da 100644 --- a/crates/bevy_ecs/src/bundle/insert.rs +++ b/crates/bevy_ecs/src/bundle/insert.rs @@ -9,7 +9,7 @@ use crate::{ }, bundle::{ArchetypeMoveType, Bundle, BundleId, BundleInfo, DynamicBundle, InsertMode}, change_detection::{MaybeLocation, Tick}, - component::{Components, StorageType}, + component::{Components, ComponentsConstraintError, StorageType}, entity::{Entities, Entity, EntityLocation}, event::EntityComponentsTrigger, lifecycle::{Add, Discard, Insert, ADD, DISCARD, INSERT}, @@ -38,7 +38,7 @@ impl<'w> BundleInserter<'w> { world: &'w mut World, archetype_id: ArchetypeId, change_tick: Tick, - ) -> Self { + ) -> Result { let bundle_id = world.register_bundle_info::(); // SAFETY: We just ensured this bundle exists @@ -56,17 +56,21 @@ impl<'w> BundleInserter<'w> { archetype_id: ArchetypeId, bundle_id: BundleId, change_tick: Tick, - ) -> Self { + ) -> Result { // SAFETY: We will not make any accesses to the command queue, component or resource data of this world let bundle_info = world.bundles.get_unchecked(bundle_id); let bundle_id = bundle_info.id(); + + // This is a good place to trigger the [`ComponentsContraintError`] event, but the upper layer still needs to write Components let (new_archetype_id, is_new_created) = bundle_info.insert_bundle_into_archetype( &mut world.archetypes, &mut world.storages, &world.components, &world.observers, archetype_id, - ); + )?; // We chose to keep propagating it when violating. + + // TODO: trigger an event // SAFETY: // - The caller ensures `archetype_id` is valid. @@ -115,7 +119,7 @@ impl<'w> BundleInserter<'w> { .into_deferred() .trigger(ArchetypeCreated(new_archetype_id)); } - inserter + Ok(inserter) } // A non-generic prelude to insert used to minimize duplicated monomorphized code. @@ -510,12 +514,12 @@ impl BundleInfo { components: &Components, observers: &Observers, archetype_id: ArchetypeId, - ) -> (ArchetypeId, bool) { + ) -> Result<(ArchetypeId, bool), ComponentsConstraintError> { if let Some(archetype_after_insert_id) = archetypes[archetype_id] .edges() .get_archetype_after_bundle_insert(self.id) { - return (archetype_after_insert_id, false); + return Ok((archetype_after_insert_id, false)); } let mut new_table_components = Vec::new(); let mut new_sparse_set_components = Vec::new(); @@ -569,7 +573,7 @@ impl BundleInfo { added, existing, ); - (archetype_id, false) + Ok((archetype_id, false)) } else { let table_id; let table_components; @@ -613,7 +617,7 @@ impl BundleInfo { table_components, sparse_set_components, ) - }; + }?; // Add an edge from the old archetype to the new archetype. archetypes[archetype_id] @@ -626,7 +630,7 @@ impl BundleInfo { added, existing, ); - (new_archetype_id, is_new_created) + Ok((new_archetype_id, is_new_created)) } } } diff --git a/crates/bevy_ecs/src/bundle/remove.rs b/crates/bevy_ecs/src/bundle/remove.rs index 6b9f59186a835..d75c73acf8046 100644 --- a/crates/bevy_ecs/src/bundle/remove.rs +++ b/crates/bevy_ecs/src/bundle/remove.rs @@ -411,14 +411,19 @@ impl BundleInfo { }; } - let (new_archetype_id, is_new_created) = archetypes.get_id_or_insert( + match archetypes.get_id_or_insert( components, observers, next_table_id, next_table_components, next_sparse_set_components, - ); - (Some(new_archetype_id), is_new_created) + ) { + Ok((new_archetype_id, is_new_created)) => (Some(new_archetype_id), is_new_created), + Err(_) => { + // Constraint violated (RESTRICT): reject the removal. + (None, false) + } + } }; let current_archetype = &mut archetypes[archetype_id]; // Cache the result in an edge. diff --git a/crates/bevy_ecs/src/bundle/spawner.rs b/crates/bevy_ecs/src/bundle/spawner.rs index 8a43899bb28a2..e34f04e7f3611 100644 --- a/crates/bevy_ecs/src/bundle/spawner.rs +++ b/crates/bevy_ecs/src/bundle/spawner.rs @@ -6,6 +6,7 @@ use crate::{ archetype::{Archetype, ArchetypeCreated, ArchetypeId, SpawnBundleStatus}, bundle::{Bundle, BundleId, BundleInfo, DynamicBundle, InsertMode}, change_detection::{MaybeLocation, Tick}, + component::ComponentsConstraintError, entity::{Entity, EntityAllocator, EntityLocation}, event::EntityComponentsTrigger, lifecycle::{Add, Insert, ADD, INSERT}, @@ -25,7 +26,10 @@ pub(crate) struct BundleSpawner<'w> { impl<'w> BundleSpawner<'w> { #[inline] - pub fn new(world: &'w mut World, change_tick: Tick) -> Self { + pub fn new( + world: &'w mut World, + change_tick: Tick, + ) -> Result { let bundle_id = world.register_bundle_info::(); // SAFETY: we initialized this bundle_id in `init_info` @@ -41,7 +45,7 @@ impl<'w> BundleSpawner<'w> { world: &'w mut World, bundle_id: BundleId, change_tick: Tick, - ) -> Self { + ) -> Result { let bundle_info = world.bundles.get_unchecked(bundle_id); let (new_archetype_id, is_new_created) = bundle_info.insert_bundle_into_archetype( &mut world.archetypes, @@ -49,7 +53,7 @@ impl<'w> BundleSpawner<'w> { &world.components, &world.observers, ArchetypeId::EMPTY, - ); + )?; let archetype = &mut world.archetypes[new_archetype_id]; let table = &mut world.storages.tables[archetype.table_id()]; @@ -66,7 +70,7 @@ impl<'w> BundleSpawner<'w> { .into_deferred() .trigger(ArchetypeCreated(new_archetype_id)); } - spawner + Ok(spawner) } #[inline] diff --git a/crates/bevy_ecs/src/component/constraint.rs b/crates/bevy_ecs/src/component/constraint.rs new file mode 100644 index 0000000000000..08bdc52d8f723 --- /dev/null +++ b/crates/bevy_ecs/src/component/constraint.rs @@ -0,0 +1,475 @@ +//! Constraint system for component relationships. +//! +//! Provides four primitives (`Required`, `Not`, `And`, `Or`) that can express +//! any boolean constraint over component sets. + +use alloc::{boxed::Box, vec::Vec}; +use core::{error::Error, fmt}; +use fixedbitset::FixedBitSet; + +use super::ComponentId; + +/// [`ComponentConstraint`] stored in [`ComponentInfo`] +#[derive(Debug, Clone)] +pub struct ComponentConstraint { + /// Compiled DNF form + pub dnf: Option, + + /// [`ComponentId`] set + pub only: Option, +} + +impl ComponentConstraint { + /// build from expr + pub fn from_expr(expr: Option, only: Option>) -> Self { + ComponentConstraint { + dnf: expr.map(|e| e.to_dnf()), + only: only.map(|ids| { + let max = ids.iter().map(|id| id.index()).max().unwrap_or(0); + let mut bits = FixedBitSet::with_capacity(max + 1); + for id in &ids { + bits.insert(id.index()); + } + bits + }), + } + } +} + +/// Error returned when a component constraint is violated during archetype creation. +#[derive(Debug)] +pub struct ComponentsConstraintError { + /// The component whose constraint was violated. + pub component: ComponentId, + /// Components that were missing. Each entry is one `required` field in [`DnfClause`] + pub missing: Vec>, + /// Components that were present but forbidden. Each entry is one `forbidden` field in [`DnfClause`] + pub conflicting: Vec>, + /// Components that were present but been disallowed(not in `only`) + pub disallowed: Vec, +} + +impl fmt::Display for ComponentsConstraintError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Constraint violated for component {:?}", self.component)?; + if !self.missing.is_empty() { + write!(f, ", missing: {:?}", self.missing)?; + } + if !self.conflicting.is_empty() { + write!(f, ", conflicts with: {:?}", self.conflicting)?; + } + if !self.disallowed.is_empty() { + write!( + f, + ", disallowed with {:?}(not in \"only\" field)", + self.disallowed + )?; + } + Ok(()) + } +} + +// Currently we use `core::error::Error` since there is only error propagation path from `get_id_or_insert` in [`Archetype`] impl +impl Error for ComponentsConstraintError {} + +/// A constraint expression over component presence +/// +/// Constraints are attached to a component and express: "if I exist in an archetype, +/// then this predicate must hold over that archetype's component set." +#[derive(Debug, Clone)] +pub enum ConstraintExpr { + /// The given component must be present. + Required(ComponentId), + /// Negates the inner constraint. + Not(Box), + /// All inner constraints must hold. + And(Vec), + /// At least one inner constraint must hold. + Or(Vec), +} + +/// Primitive: "I need this component in current archetype." +pub fn require(id: ComponentId) -> ConstraintExpr { + ConstraintExpr::Required(id) +} + +/// The component must NOT be present. Sugar for `not(require(id))`. +pub fn forbid(id: ComponentId) -> ConstraintExpr { + ConstraintExpr::Not(Box::new(ConstraintExpr::Required(id))) +} + +/// Negates a constraint +pub fn not(constraint: ConstraintExpr) -> ConstraintExpr { + ConstraintExpr::Not(Box::new(constraint)) +} + +/// All constraints must hold +pub fn and(constraints: impl Into>) -> ConstraintExpr { + ConstraintExpr::And(constraints.into()) +} + +/// At least one constraint must hold +pub fn or(constraints: impl Into>) -> ConstraintExpr { + ConstraintExpr::Or(constraints.into()) +} + +impl ConstraintExpr { + /// Convert this constraint into Disjunctive Normal Form for efficient evaluation. + pub(super) fn to_dnf(&self) -> Dnf { + let clauses = to_dnf_clauses(self); + Dnf { clauses } + } +} + +/// A single clause in DNF form: a conjunction of positive and negative literals. +#[derive(Debug, Clone)] +pub struct DnfClause { + /// Components that must be present. + pub required: FixedBitSet, + /// Components that must NOT be present. + pub forbidden: FixedBitSet, +} + +impl DnfClause { + fn new() -> Self { + Self { + required: FixedBitSet::new(), + forbidden: FixedBitSet::new(), + } + } + + /// Returns `true` if this clause is satisfiable (required and forbidden don't overlap). + fn is_satisfiable(&self) -> bool { + self.required.intersection(&self.forbidden).count() == 0 + } + + /// Check if this clause is satisfied by the given archetype component bitset. + fn satisfied_by(&self, archetype_bits: &FixedBitSet) -> bool { + self.required.is_subset(archetype_bits) + && self.forbidden.intersection(archetype_bits).count() == 0 + } + + /// Merge another clause into this one (AND semantics: union both sets). + fn merge(&self, other: &DnfClause) -> DnfClause { + let mut required = self.required.clone(); + let mut forbidden = self.forbidden.clone(); + // Grow to fit if needed + let max_req = other.required.len().max(required.len()); + let max_forb = other.forbidden.len().max(forbidden.len()); + required.grow(max_req); + forbidden.grow(max_forb); + required.union_with(&other.required); + forbidden.union_with(&other.forbidden); + DnfClause { + required, + forbidden, + } + } +} + +/// A constraint in Disjunctive Normal Form: a disjunction (OR) of conjunctive clauses. +#[derive(Debug, Clone)] +pub struct Dnf { + clauses: Vec, +} + +impl Dnf { + /// Empty + pub fn empty() -> Self { + Self { + clauses: Vec::new(), + } + } + + /// A DNF that is always satisfied (True). + pub fn tautology() -> Self { + Self { + clauses: alloc::vec![DnfClause::new()], + } + } + + /// Check if this DNF is satisfied by the given archetype component bitset. + pub fn satisfied_by(&self, archetype_bits: &FixedBitSet) -> bool { + self.clauses + .iter() + .any(|clause| clause.satisfied_by(archetype_bits)) + } + + /// Returns the clauses of this DNF. + pub fn clauses(&self) -> &[DnfClause] { + &self.clauses + } +} + +/// Convert a [`Constraint`] tree into a list of [`DnfClause`]s. +fn to_dnf_clauses(constraint: &ConstraintExpr) -> Vec { + match constraint { + ConstraintExpr::Required(id) => { + let mut clause = DnfClause::new(); + let idx = id.index(); + clause.required.grow(idx + 1); + clause.required.insert(idx); + alloc::vec![clause] + } + ConstraintExpr::Not(inner) => negate_dnf(&to_dnf_clauses(inner)), + ConstraintExpr::And(children) => { + let mut result = alloc::vec![DnfClause::new()]; // single empty clause = true + for child in children { + let child_clauses = to_dnf_clauses(child); + result = and_dnf(result, child_clauses); + } + result + } + ConstraintExpr::Or(children) => { + let mut result = Vec::new(); + for child in children { + result.extend(to_dnf_clauses(child)); + } + // Remove unsatisfiable clauses + result.retain(DnfClause::is_satisfiable); + result + } + } +} + +/// AND two DNFs: distribute (cross-product of clauses, merging each pair). +fn and_dnf(left: Vec, right: Vec) -> Vec { + let mut result = Vec::with_capacity(left.len() * right.len()); + for l in &left { + for r in &right { + let merged = l.merge(r); + if merged.is_satisfiable() { + result.push(merged); + } + } + } + result +} + +/// Negate a DNF. NOT(OR(c1, c2, ...)) = AND(NOT(c1), NOT(c2), ...). +/// Each clause negation: NOT(AND(required, NOT(forbidden))) uses De Morgan's law. +fn negate_dnf(clauses: &[DnfClause]) -> Vec { + // Negate each clause into a small DNF, then AND them all together. + let mut result = alloc::vec![DnfClause::new()]; // tautology + for clause in clauses { + let negated = negate_clause(clause); + result = and_dnf(result, negated); + } + result +} + +/// Negate a single conjunctive clause. +/// NOT(a AND b AND NOT c AND NOT d) = (NOT a) OR (NOT b) OR c OR d +fn negate_clause(clause: &DnfClause) -> Vec { + let mut result = Vec::new(); + // For each required bit, create a clause that forbids it + for idx in clause.required.ones() { + let mut c = DnfClause::new(); + c.forbidden.grow(idx + 1); + c.forbidden.insert(idx); + result.push(c); + } + // For each forbidden bit, create a clause that requires it + for idx in clause.forbidden.ones() { + let mut c = DnfClause::new(); + c.required.grow(idx + 1); + c.required.insert(idx); + result.push(c); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bits(ids: &[usize]) -> FixedBitSet { + let max = ids.iter().copied().max().unwrap_or(0); + let mut bs = FixedBitSet::with_capacity(max + 1); + for &id in ids { + bs.insert(id); + } + bs + } + + fn cid(index: usize) -> ComponentId { + ComponentId::new(index) + } + + #[test] + fn mutual_exclusion() { + // Player and Enemy cannot coexist + let c = forbid(cid(2)); // forbid Enemy(2) + let dnf = c.to_dnf(); + assert!(dnf.satisfied_by(&bits(&[1]))); // Player only + assert!(!dnf.satisfied_by(&bits(&[1, 2]))); // Player + Enemy + } + + #[test] + fn conditional_dependency() { + // if Mana(3) exists then Caster(4) must exist + // or(not(Mana), required(Caster)) + let c = or([forbid(cid(3)), require(cid(4))]); + let dnf = c.to_dnf(); + assert!(dnf.satisfied_by(&bits(&[1, 2]))); // no Mana, no Caster: ok + assert!(dnf.satisfied_by(&bits(&[3, 4]))); // Mana + Caster: ok + assert!(!dnf.satisfied_by(&bits(&[3]))); // Mana without Caster: fail + } + + #[test] + fn contradiction_detected() { + // A requires B, B requires C, C forbids A + let c_constraint = forbid(cid(0)); // C forbids A(0) + let dnf = c_constraint.to_dnf(); + assert!(!dnf.satisfied_by(&bits(&[0, 1, 2]))); + } + + use crate::{component::Component, world::World}; + + #[derive(Component, Default)] + struct Health; + + #[derive(Component, Default)] + struct Mana; + + // Player requires Health via constraint + #[derive(Component)] + #[constraint(require(Health))] + struct Player; + + // Ally forbids Enemy + #[derive(Component, Default)] + #[constraint(forbid(Enemy))] + struct Ally; + + #[derive(Component, Default)] + struct Enemy; + + // Caster requires either Mana or Scroll + #[derive(Component, Default)] + struct Scroll; + + #[derive(Component)] + #[constraint(or(require(Mana), require(Scroll)))] + struct Caster; + + // Warrior can only coexist with Health and Armor, nothing else + #[derive(Component, Default)] + struct Armor; + + #[derive(Component)] + #[constraint(only(Health, Armor))] + struct Warrior; + + // Knight has both only + expr constraints + #[derive(Component)] + #[constraint(require(Health))] + #[constraint(only(Health, Armor))] + struct Knight; + + #[test] + fn constraint_only_satisfied() { + let mut world = World::new(); + // Warrior + Health + Armor -> all in only set -> ok + let e = world.spawn((Warrior, Health, Armor)).id(); + assert!(world.entity(e).contains::()); + assert!(world.entity(e).contains::()); + assert!(world.entity(e).contains::()); + } + + #[test] + fn constraint_only_subset_satisfied() { + let mut world = World::new(); + // Warrior + Health -> only has {Health, Armor}, subset is fine + let e = world.spawn((Warrior, Health)).id(); + assert!(world.entity(e).contains::()); + } + + #[test] + fn constraint_only_violated() { + let mut world = World::new(); + // Warrior + Health + Enemy -> Enemy not in only set -> rejected + let e = world.spawn((Warrior, Health, Enemy)).id(); + assert!(!world.entity(e).contains::()); + } + + #[test] + fn constraint_only_with_insert_violated() { + let mut world = World::new(); + // Warrior + Health -> ok + let e = world.spawn((Warrior, Health)).id(); + assert!(world.entity(e).contains::()); + // Insert Enemy -> would violate only -> rejected, entity stays as {Warrior, Health} + world.entity_mut(e).insert(Enemy); + assert!(world.entity(e).contains::()); + assert!(!world.entity(e).contains::()); + } + + #[test] + fn constraint_only_and_expr_satisfied() { + let mut world = World::new(); + // Knight requires Health + only allows {Health, Armor} + let e = world.spawn((Knight, Health)).id(); + assert!(world.entity(e).contains::()); + assert!(world.entity(e).contains::()); + } + + #[test] + fn constraint_only_and_expr_violated_missing() { + let mut world = World::new(); + // Knight requires Health but not provided -> expr constraint fails + let e = world.spawn(Knight).id(); + assert!(!world.entity(e).contains::()); + } + + #[test] + fn constraint_only_and_expr_violated_disallowed() { + let mut world = World::new(); + // Knight + Health + Enemy -> Health satisfies require, but Enemy violates only + let e = world.spawn((Knight, Health, Enemy)).id(); + assert!(!world.entity(e).contains::()); + } + + #[test] + fn constraint_require_satisfied() { + let mut world = World::new(); + // Player + Health satisfies require(Health) + let e = world.spawn((Player, Health)).id(); + assert!(world.entity(e).contains::()); + assert!(world.entity(e).contains::()); + } + + #[test] + fn constraint_require_violated() { + let mut world = World::new(); + // Player without Health -> constraint violated -> entity stays in empty archetype + let e = world.spawn(Player).id(); + // RESTRICT: the insert should be rejected + assert!(!world.entity(e).contains::()); + } + + #[test] + fn constraint_forbid_violated() { + let mut world = World::new(); + // Ally + Enemy -> forbid violated + let e = world.spawn((Ally, Enemy)).id(); + assert!(!world.entity(e).contains::()); + assert!(!world.entity(e).contains::()); + } + + #[test] + fn constraint_or_branch() { + let mut world = World::new(); + // Caster + Mana -> or(require(Mana), require(Scroll)) satisfied via first branch + let e = world.spawn((Caster, Mana)).id(); + assert!(world.entity(e).contains::()); + } + + #[test] + fn constraint_or_violated() { + let mut world = World::new(); + // Caster alone -> neither Mana nor Scroll + let e = world.spawn(Caster).id(); + assert!(!world.entity(e).contains::()); + } +} diff --git a/crates/bevy_ecs/src/component/info.rs b/crates/bevy_ecs/src/component/info.rs index 567cbb7bb77c4..ba205f1ad99e7 100644 --- a/crates/bevy_ecs/src/component/info.rs +++ b/crates/bevy_ecs/src/component/info.rs @@ -15,8 +15,8 @@ use indexmap::IndexSet; use crate::{ archetype::ArchetypeFlags, component::{ - Component, ComponentCloneBehavior, ComponentMutability, QueuedComponents, - RequiredComponents, StorageType, + Component, ComponentCloneBehavior, ComponentConstraint, ComponentMutability, + QueuedComponents, RequiredComponents, StorageType, }, lifecycle::ComponentHooks, query::DebugCheckedUnwrap as _, @@ -37,6 +37,9 @@ pub struct ComponentInfo { /// The set of components that require this components. /// Invariant: components in this set always appear after the components that they require. pub(super) required_by: IndexSet, + /// If present, any archetype containing this + /// component must satisfy this constraint. + pub(super) constraint: Option, } impl ComponentInfo { @@ -110,6 +113,7 @@ impl ComponentInfo { hooks: Default::default(), required_components: Default::default(), required_by: Default::default(), + constraint: None, } } @@ -149,6 +153,11 @@ impl ComponentInfo { pub fn relationship_accessor(&self) -> Option<&RelationshipAccessor> { self.descriptor.relationship_accessor.accessor() } + + /// Returns the constraint DNF for this component, if any. + pub fn constraint(&self) -> Option<&ComponentConstraint> { + self.constraint.as_ref() + } } /// A value which uniquely identifies the type of a [`Component`] or [`Resource`] within a diff --git a/crates/bevy_ecs/src/component/mod.rs b/crates/bevy_ecs/src/component/mod.rs index c12dfb06e1524..353df6f67d704 100644 --- a/crates/bevy_ecs/src/component/mod.rs +++ b/crates/bevy_ecs/src/component/mod.rs @@ -2,12 +2,14 @@ mod clone; mod constants; +mod constraint; mod info; mod register; mod required; pub use clone::*; pub use constants::*; +pub use constraint::*; pub use info::*; pub use register::*; pub use required::*; @@ -556,6 +558,13 @@ pub trait Component: Send + Sync + 'static { ) { } + /// Returns a constraint for this component, if any. + fn register_constraint( + _registrator: &mut ComponentsRegistrator, + ) -> Option { + None + } + /// Called when registering this component, allowing to override clone function (or disable cloning altogether) for this component. /// /// See [Clone Behaviors section of `EntityCloner`](crate::entity::EntityCloner#clone-behaviors) to understand how this affects handler priority. diff --git a/crates/bevy_ecs/src/component/register.rs b/crates/bevy_ecs/src/component/register.rs index 8f5f175efc1c9..78ee5dbbdb3a4 100644 --- a/crates/bevy_ecs/src/component/register.rs +++ b/crates/bevy_ecs/src/component/register.rs @@ -4,7 +4,9 @@ use bevy_utils::TypeIdMap; use core::any::Any; use core::{any::TypeId, fmt::Debug, ops::Deref}; -use crate::component::{enforce_no_required_components_recursion, RequiredComponentsRegistrator}; +use crate::component::{ + enforce_no_required_components_recursion, ComponentConstraint, RequiredComponentsRegistrator, +}; use crate::lifecycle::ComponentHooks; use crate::{ component::{ @@ -167,6 +169,7 @@ impl<'w> ComponentsRegistrator<'w> { ComponentDescriptor::new::, T::register_required_components, ComponentHooks::update_from_component::, + T::register_constraint, ) } @@ -177,6 +180,7 @@ impl<'w> ComponentsRegistrator<'w> { descriptor: fn() -> ComponentDescriptor, register_required_components: fn(ComponentId, &mut RequiredComponentsRegistrator), update_from_component: fn(&mut ComponentHooks) -> &mut ComponentHooks, + register_constraint: fn(&mut ComponentsRegistrator) -> Option, ) -> ComponentId { if let Some(&id) = self.indices.get(&type_id) { enforce_no_required_components_recursion(self, &self.recursion_check_stack, id); @@ -205,6 +209,7 @@ impl<'w> ComponentsRegistrator<'w> { descriptor(), register_required_components, update_from_component, + register_constraint, ); } id @@ -221,6 +226,7 @@ impl<'w> ComponentsRegistrator<'w> { descriptor: ComponentDescriptor, register_required_components: fn(ComponentId, &mut RequiredComponentsRegistrator), update_from_component: fn(&mut ComponentHooks) -> &mut ComponentHooks, + register_constraint: fn(&mut ComponentsRegistrator) -> Option, ) { // SAFETY: ensured by caller. unsafe { @@ -245,6 +251,9 @@ impl<'w> ComponentsRegistrator<'w> { } self.recursion_check_stack.pop(); + // Resolve and set constraint if defined. + let constraint = register_constraint(self); + // SAFETY: we just inserted it in `register_component_inner` let info = unsafe { &mut self @@ -259,6 +268,19 @@ impl<'w> ComponentsRegistrator<'w> { update_from_component(&mut info.hooks); info.required_components = required_components; + + // set [`ComponentConstraint`] to [`ComponentInfo`] + if let Some(constraint) = constraint { + info.constraint = Some(constraint); + } + + // Ensure myself is in whitelist(`only` field) + if let Some(constraint) = &mut info.constraint + && let Some(only) = &mut constraint.only + { + only.grow(id.index() + 1); + only.insert(id.index()); + } } /// Registers a component described by `descriptor`. @@ -536,6 +558,7 @@ impl<'w> ComponentsQueuedRegistrator<'w> { descriptor, T::register_required_components, ComponentHooks::update_from_component::, + T::register_constraint, ); } }, diff --git a/crates/bevy_ecs/src/world/entity_access/world_mut.rs b/crates/bevy_ecs/src/world/entity_access/world_mut.rs index c82590159cdb2..ffcfd8ac562ae 100644 --- a/crates/bevy_ecs/src/world/entity_access/world_mut.rs +++ b/crates/bevy_ecs/src/world/entity_access/world_mut.rs @@ -1055,8 +1055,11 @@ impl<'w> EntityWorldMut<'w> { let change_tick = self.world.change_tick(); // SAFETY: // - `location.archetype_id` is part of a valid `EntityLocation`. - let mut bundle_inserter = - unsafe { BundleInserter::new::(self.world, location.archetype_id, change_tick) }; + let Ok(mut bundle_inserter) = + (unsafe { BundleInserter::new::(self.world, location.archetype_id, change_tick) }) + else { + return self; + }; // SAFETY: // - `location` matches current entity and thus must currently exist in the source // archetype for this inserter and its location within the archetype. @@ -1139,8 +1142,11 @@ impl<'w> EntityWorldMut<'w> { ); let storage_type = self.world.bundles.get_storage_unchecked(bundle_id); - let bundle_inserter = - BundleInserter::new_with_id(self.world, location.archetype_id, bundle_id, change_tick); + let Ok(bundle_inserter) = + BundleInserter::new_with_id(self.world, location.archetype_id, bundle_id, change_tick) + else { + return self; + }; self.location = Some(insert_dynamic_bundle( bundle_inserter, @@ -1198,8 +1204,14 @@ impl<'w> EntityWorldMut<'w> { ); let mut storage_types = core::mem::take(self.world.bundles.get_storages_unchecked(bundle_id)); - let bundle_inserter = - BundleInserter::new_with_id(self.world, location.archetype_id, bundle_id, change_tick); + let Ok(bundle_inserter) = + BundleInserter::new_with_id(self.world, location.archetype_id, bundle_id, change_tick) + else { + // components constraints violation failed, put it back + *self.world.bundles.get_storages_unchecked(bundle_id) = + core::mem::take(&mut storage_types); + return self; + }; self.location = Some(insert_dynamic_bundle( bundle_inserter, diff --git a/crates/bevy_ecs/src/world/mod.rs b/crates/bevy_ecs/src/world/mod.rs index 23cb6a48395c5..ecedce2569b5b 100644 --- a/crates/bevy_ecs/src/world/mod.rs +++ b/crates/bevy_ecs/src/world/mod.rs @@ -1125,7 +1125,10 @@ impl World { caller: MaybeLocation, ) -> EntityWorldMut<'_> { let change_tick = self.change_tick(); - let mut bundle_spawner = BundleSpawner::new::(self, change_tick); + let Ok(mut bundle_spawner) = BundleSpawner::new::(self, change_tick) else { + // The components of this [`Bundle`] has a [`Constraint`] and is in violation. spawn failed and the entity's components are empty. + return self.spawn_empty_at_unchecked(entity, caller); + }; let (bundle, entity_location) = bundle.partial_move(|bundle| { // SAFETY: // - `B` matches `bundle_spawner`'s type @@ -2486,16 +2489,19 @@ impl World { panic!("error[B0003]: Could not insert a bundle (of type `{}`) for entity {first_entity} because: {err}. See: https://bevyengine.org/learn/errors/b0003", core::any::type_name::()); } Ok(first_location) => { + // SAFETY: we initialized this bundle_id in `register_info` + let Ok(first_inserter) = (unsafe { + BundleInserter::new_with_id( + self, + first_location.archetype_id, + bundle_id, + change_tick, + ) + }) else { + return; + }; let mut cache = InserterArchetypeCache { - // SAFETY: we initialized this bundle_id in `register_info` - inserter: unsafe { - BundleInserter::new_with_id( - self, - first_location.archetype_id, - bundle_id, - change_tick, - ) - }, + inserter: first_inserter, archetype_id: first_location.archetype_id, }; move_as_ptr!(first_bundle); @@ -2512,39 +2518,40 @@ impl World { }; for (entity, bundle) in batch_iter { - match cache.inserter.entities().get_spawned(entity) { - Ok(location) => { - if location.archetype_id != cache.archetype_id { - cache = InserterArchetypeCache { - // SAFETY: we initialized this bundle_id in `register_info` - inserter: unsafe { - BundleInserter::new_with_id( - self, - location.archetype_id, - bundle_id, - change_tick, - ) - }, - archetype_id: location.archetype_id, - } - } - move_as_ptr!(bundle); - // SAFETY: `entity` is valid, `location` matches entity, bundle matches inserter - unsafe { - cache.inserter.insert( - entity, - location, - bundle, - insert_mode, - caller, - RelationshipHookMode::Run, - ) - }; - } + let location = match cache.inserter.entities().get_spawned(entity) { + Ok(loc) => loc, Err(err) => { panic!("error[B0003]: Could not insert a bundle (of type `{}`) for entity {entity} because: {err}. See: https://bevyengine.org/learn/errors/b0003", core::any::type_name::()); } + }; + if location.archetype_id != cache.archetype_id { + cache = InserterArchetypeCache { + // SAFETY: we initialized this bundle_id in `register_info`. + // Constraint was already validated when the first inserter was created. + inserter: unsafe { + BundleInserter::new_with_id( + self, + location.archetype_id, + bundle_id, + change_tick, + ) + .expect("constraint already validated for this bundle type, THIS SHOULD NOT HAPPEN") + }, + archetype_id: location.archetype_id, + } } + move_as_ptr!(bundle); + // SAFETY: `entity` is valid, `location` matches entity, bundle matches inserter + unsafe { + cache.inserter.insert( + entity, + location, + bundle, + insert_mode, + caller, + RelationshipHookMode::Run, + ) + }; } } } @@ -2634,16 +2641,19 @@ impl World { let cache = loop { if let Some((first_entity, first_bundle)) = batch_iter.next() { if let Ok(first_location) = self.entities().get_spawned(first_entity) { + // SAFETY: we initialized this bundle_id in `register_bundle_info` + let Ok(first_inserter) = (unsafe { + BundleInserter::new_with_id( + self, + first_location.archetype_id, + bundle_id, + change_tick, + ) + }) else { + break None; + }; let mut cache = InserterArchetypeCache { - // SAFETY: we initialized this bundle_id in `register_bundle_info` - inserter: unsafe { - BundleInserter::new_with_id( - self, - first_location.archetype_id, - bundle_id, - change_tick, - ) - }, + inserter: first_inserter, archetype_id: first_location.archetype_id, }; @@ -2673,40 +2683,42 @@ impl World { if let Some(mut cache) = cache { for (entity, bundle) in batch_iter { - if let Ok(location) = cache.inserter.entities().get_spawned(entity) { - if location.archetype_id != cache.archetype_id { - cache = InserterArchetypeCache { - // SAFETY: we initialized this bundle_id in `register_info` - inserter: unsafe { - BundleInserter::new_with_id( - self, - location.archetype_id, - bundle_id, - change_tick, - ) - }, - archetype_id: location.archetype_id, - } - } - - move_as_ptr!(bundle); - // SAFETY: - // - `entity` is valid, `location` matches entity, bundle matches inserter - // - `apply_effect` is never called on this bundle. - // - `bundle` is not be accessed or dropped after this. - unsafe { - cache.inserter.insert( - entity, - location, - bundle, - insert_mode, - caller, - RelationshipHookMode::Run, - ) - }; - } else { + let Ok(location) = cache.inserter.entities().get_spawned(entity) else { invalid_entities.push(entity); + continue; + }; + if location.archetype_id != cache.archetype_id { + cache = InserterArchetypeCache { + // SAFETY: we initialized this bundle_id in `register_info`. + // Constraint was already validated when the first inserter was created. + inserter: unsafe { + BundleInserter::new_with_id( + self, + location.archetype_id, + bundle_id, + change_tick, + ) + .expect("constraint already validated for this bundle type, THIS SHOULD NOT HAPPEN") + }, + archetype_id: location.archetype_id, + }; } + + move_as_ptr!(bundle); + // SAFETY: + // - `entity` is valid, `location` matches entity, bundle matches inserter + // - `apply_effect` is never called on this bundle. + // - `bundle` is not be accessed or dropped after this. + unsafe { + cache.inserter.insert( + entity, + location, + bundle, + insert_mode, + caller, + RelationshipHookMode::Run, + ) + }; } } @@ -2857,8 +2869,10 @@ impl World { let world = unsafe { entity_mut.world_mut() }; // SAFETY: // - `location.archetype_id` is part of a valid `EntityLocation`. - let mut bundle_inserter = unsafe { + let Ok(mut bundle_inserter) = (unsafe { BundleInserter::new::(world, location.archetype_id, self.ticks.changed) + }) else { + return; }; // SAFETY: // - `location` matches current entity and thus must currently exist in the source diff --git a/crates/bevy_ecs/src/world/spawn_batch.rs b/crates/bevy_ecs/src/world/spawn_batch.rs index 80424a731eabb..091541a51b111 100644 --- a/crates/bevy_ecs/src/world/spawn_batch.rs +++ b/crates/bevy_ecs/src/world/spawn_batch.rs @@ -18,8 +18,8 @@ where I::Item: Bundle, { inner: I, - spawner: BundleSpawner<'w>, - allocator: AllocEntitiesIterator<'w>, + spawner: Option>, + allocator: Option>, caller: MaybeLocation, } @@ -36,15 +36,23 @@ where let (lower, upper) = iter.size_hint(); let length = upper.unwrap_or(lower); - let mut spawner = BundleSpawner::new::(world, change_tick); - spawner.reserve_storage(length); - let allocator = spawner.allocator().alloc_many(length as u32); - - Self { - inner: iter, - allocator, - spawner, - caller, + match BundleSpawner::new::(world, change_tick) { + Ok(mut spawner) => { + spawner.reserve_storage(length); + let allocator = spawner.allocator().alloc_many(length as u32); + Self { + inner: iter, + spawner: Some(spawner), + allocator: Some(allocator), + caller, + } + } + Err(_) => Self { + inner: iter, + spawner: None, + allocator: None, + caller, + }, } } } @@ -58,12 +66,16 @@ where // Iterate through self in order to spawn remaining bundles. for _ in &mut *self {} // Free all the over allocated entities. - for e in self.allocator.by_ref() { - self.spawner.allocator().free(e); + if let Some(ref mut allocator) = self.allocator { + for e in allocator.by_ref() { + self.spawner.as_mut().unwrap().allocator().free(e); + } } // Apply any commands from those operations. - // SAFETY: `self.spawner` will be dropped immediately after this call. - unsafe { self.spawner.flush_commands() }; + if let Some(ref mut spawner) = self.spawner { + // SAFETY: `self.spawner` will be dropped immediately after this call. + unsafe { spawner.flush_commands() }; + } } } @@ -76,21 +88,29 @@ where fn next(&mut self) -> Option { let bundle = self.inner.next()?; + let spawner = self.spawner.as_mut()?; move_as_ptr!(bundle); - Some(if let Some(bulk) = self.allocator.next() { - // SAFETY: bundle matches spawner type and we just allocated it - unsafe { - self.spawner.spawn_at(bulk, bundle, self.caller); - } - bulk - } else { - // SAFETY: bundle matches spawner type - unsafe { self.spawner.spawn(bundle, self.caller) } - }) + Some( + if let Some(allocator) = &mut self.allocator + && let Some(bulk) = allocator.next() + { + // SAFETY: bundle matches spawner type and we just allocated it + unsafe { + spawner.spawn_at(bulk, bundle, self.caller); + } + bulk + } else { + // SAFETY: bundle matches spawner type + unsafe { spawner.spawn(bundle, self.caller) } + }, + ) } #[inline] fn size_hint(&self) -> (usize, Option) { + if self.spawner.is_none() { + return (0, Some(0)); + } self.inner.size_hint() } } @@ -101,6 +121,9 @@ where T: Bundle, { fn len(&self) -> usize { + if self.spawner.is_none() { + return 0; + } self.inner.len() } } diff --git a/examples/ecs/component_constraints.rs b/examples/ecs/component_constraints.rs new file mode 100644 index 0000000000000..4afb6ecd878bf --- /dev/null +++ b/examples/ecs/component_constraints.rs @@ -0,0 +1,129 @@ +//! Demonstrates the component constraint system. +//! +//! Constraints allow declaring rules about which components can coexist +//! in the same archetype. When a constraint is violated, the operation +//! is rejected (RESTRICT) and the entity stays in its previous state. +//! +//! Available constraint primitives: +//! - `require(T)`: component T must be present +//! - `forbid(T)`: component T must NOT be present +//! - `and(...)`: all sub-constraints must hold +//! - `or(...)`: at least one sub-constraint must hold +//! - `not(...)`: negates a sub-constraint +//! - `only(T1, T2, ...)`: only these components (plus self) are allowed + +use bevy::{log::{self, LogPlugin}, prelude::*}; + +// --- Component definitions with constraints --- + +#[derive(Component, Default, Debug)] +struct Health(i32); + +#[derive(Component, Default, Debug)] +struct Mana(i32); + +#[derive(Component, Default, Debug)] +struct Armor(i32); + +#[derive(Component, Default, Debug)] +struct Enemy; + +#[derive(Component, Default, Debug)] +struct Scroll; + +/// Player requires Health - cannot exist without it. +#[derive(Component, Debug)] +#[constraint(require(Health))] +struct Player; + +/// Ally forbids Enemy - they cannot coexist on the same entity. +#[derive(Component, Default, Debug)] +#[constraint(forbid(Enemy))] +struct Ally; + +/// Caster requires either Mana or Scroll. +#[derive(Component, Debug)] +#[constraint(or(require(Mana), require(Scroll)))] +struct Caster; + +/// Warrior can only coexist with Health and Armor +#[derive(Component, Debug)] +#[constraint(only(Health, Armor))] +struct Warrior; + +/// Knight combines both: requires Health, and only allows Health + Armor. +#[derive(Component, Debug)] +#[constraint(require(Health))] +#[constraint(only(Health, Armor))] +struct Knight; + +fn main() { + App::new() + .add_plugins(MinimalPlugins) + .add_plugins(LogPlugin::default()) + .add_systems(Startup, demo) + .run(); +} + +fn demo(mut commands: Commands, mut exit: MessageWriter) { + println!("=== add \"--feature debug\" to see logger output ===\n"); + log::info!("\n=== require constraint ==="); + + // OK: Player + Health satisfies require(Health) + commands.spawn((Player, Health(100))); + log::info!("Spawning Player + Health(100)..."); + + // FAIL: Player alone - missing Health + commands.spawn(Player); + log::info!("Spawning Player alone (should be rejected)..."); + + log::info!("\n=== forbid constraint ==="); + + // OK: Ally without Enemy + commands.spawn(Ally); + log::info!("Spawning Ally alone..."); + + // FAIL: Ally + Enemy - forbidden + commands.spawn((Ally, Enemy)); + log::info!("Spawning Ally + Enemy (should be rejected)..."); + + log::info!("\n=== or constraint ==="); + + // OK: Caster + Mana + commands.spawn((Caster, Mana(50))); + log::info!("Spawning Caster + Mana..."); + + // OK: Caster + Scroll + commands.spawn((Caster, Scroll)); + log::info!("Spawning Caster + Scroll..."); + + // FAIL: Caster alone - neither Mana nor Scroll + commands.spawn(Caster); + log::info!("Spawning Caster alone (should be rejected)..."); + + log::info!("\n=== only constraint ==="); + + // OK: Warrior + Health + Armor - all in whitelist + commands.spawn((Warrior, Health(80), Armor(20))); + log::info!("Spawning Warrior + Health + Armor..."); + + // FAIL: Warrior + Health + Enemy - Enemy not in whitelist + commands.spawn((Warrior, Health(80), Enemy)); + log::info!("Spawning Warrior + Health + Enemy (should be rejected)..."); + + log::info!("\n=== only + require combined ==="); + + // OK: Knight + Health + commands.spawn((Knight, Health(100))); + log::info!("Spawning Knight + Health..."); + + // FAIL: Knight alone - missing required Health + commands.spawn(Knight); + log::info!("Spawning Knight alone (should be rejected)..."); + + // FAIL: Knight + Health + Enemy - Enemy violates only + commands.spawn((Knight, Health(100), Enemy)); + log::info!("Spawning Knight + Health + Enemy (should be rejected)..."); + + exit.write(AppExit::Success); +} From acc5f4f1cdfcbbe70aa08d25c32607a7ffd97926 Mon Sep 17 00:00:00 2001 From: Shaopeng Luan Date: Sat, 4 Apr 2026 15:03:01 +1100 Subject: [PATCH 2/7] fix: typo --- crates/bevy_ecs/src/archetype.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_ecs/src/archetype.rs b/crates/bevy_ecs/src/archetype.rs index d7e5209d22563..8d78685b3b7e6 100644 --- a/crates/bevy_ecs/src/archetype.rs +++ b/crates/bevy_ecs/src/archetype.rs @@ -1093,7 +1093,7 @@ impl Archetypes { if !disallowed.is_empty() { reasons.push(format!( - "These components are disallowed (not in \"any\" field): {:?}", + "These components are disallowed (not in \"only\" field): {:?}", disallowed )); } From 6b4d49e93a3cedfc931ecbb047e3d9120549bb6d Mon Sep 17 00:00:00 2001 From: Shaopeng Luan Date: Sat, 4 Apr 2026 19:06:27 +1100 Subject: [PATCH 3/7] feat: add bench --- .../components/component_constraints.rs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 benches/benches/bevy_ecs/components/component_constraints.rs diff --git a/benches/benches/bevy_ecs/components/component_constraints.rs b/benches/benches/bevy_ecs/components/component_constraints.rs new file mode 100644 index 0000000000000..51343081ae7b6 --- /dev/null +++ b/benches/benches/bevy_ecs/components/component_constraints.rs @@ -0,0 +1,170 @@ +use benches::bench; +use bevy_ecs::{component::Component, world::World}; +use criterion::Criterion; + +#[derive(Component)] +struct A; + +#[derive(Component)] +struct B; + +#[derive(Component)] +#[constraint(require(A))] +struct C; + +#[derive(Component)] +#[constraint(and(require(E), or(require(F), require(G))))] +struct D; + +#[derive(Component)] +#[constraint(require(D))] +struct E; + +#[derive(Component)] +#[constraint(and(require(E), forbid(G)))] +struct F; + +#[derive(Component)] +struct G; + +const ENTITY_COUNT: usize = 2_000; + +pub fn spawn_no_constraint(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group(bench!("spawn_no_constraint")); + + group.bench_function("static", |bencher| { + let mut world = World::new(); + bencher.iter(|| { + for _ in 0..ENTITY_COUNT { + world.spawn((A, B)); + } + world.clear_entities(); + }); + }); + + group.finish(); +} + +pub fn spawn_with_simple_constraint(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group(bench!("spawn_with_simple_constraint")); + + group.bench_function("static", |bencher| { + let mut world = World::new(); + bencher.iter(|| { + for _ in 0..ENTITY_COUNT { + world.spawn((A, C)); + } + world.clear_entities(); + }); + }); + + group.finish(); +} + +pub fn spawn_with_complex_constraint(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group(bench!("spawn_with_complex_constraint")); + + group.bench_function("static", |bencher| { + let mut world = World::new(); + bencher.iter(|| { + for _ in 0..ENTITY_COUNT { + world.spawn((D, E, F)); + } + world.clear_entities(); + }); + }); + + group.finish(); +} + +#[derive(Component)] +#[constraint(require(G2))] +struct G1; + +#[derive(Component)] +#[constraint(require(G3))] +struct G2; + +#[derive(Component)] +#[constraint(require(G4))] +struct G3; + +#[derive(Component)] +#[constraint(require(G5))] +struct G4; + +#[derive(Component)] +#[constraint(require(G6))] +struct G5; + +#[derive(Component)] +#[constraint(require(G7))] +struct G6; + +#[derive(Component)] +#[constraint(require(G8))] +struct G7; + +#[derive(Component)] +#[constraint(require(G9))] +struct G8; + +#[derive(Component)] +#[constraint(require(G10))] +struct G9; + +#[derive(Component)] +struct G10; + +#[derive(Component)] +struct H1; +#[derive(Component)] +struct H2; +#[derive(Component)] +struct H3; +#[derive(Component)] +struct H4; +#[derive(Component)] +struct H5; +#[derive(Component)] +struct H6; +#[derive(Component)] +struct H7; +#[derive(Component)] +struct H8; +#[derive(Component)] +struct H9; +#[derive(Component)] +struct H10; + +pub fn spawn_chain_10_no_constraint(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group(bench!("spawn_chain_10_no_constraint")); + + group.bench_function("static", |bencher| { + let mut world = World::new(); + bencher.iter(|| { + for _ in 0..ENTITY_COUNT { + world.spawn((H1, H2, H3, H4, H5, H6, H7, H8, H9, H10)); + } + world.clear_entities(); + }); + }); + + group.finish(); +} + +pub fn spawn_chain_10_constraint(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group(bench!("spawn_chain_10_constraint")); + + group.bench_function("static", |bencher| { + let mut world = World::new(); + bencher.iter(|| { + for _ in 0..ENTITY_COUNT { + world.spawn((G1, G2, G3, G4, G5, G6, G7, G8, G9, G10)); + } + world.clear_entities(); + }); + }); + + group.finish(); +} From 0de4ce1f1e12efb8c7ce554b963b25806b97cf85 Mon Sep 17 00:00:00 2001 From: Shaopeng Luan Date: Sat, 4 Apr 2026 19:06:41 +1100 Subject: [PATCH 4/7] chore: module export --- benches/benches/bevy_ecs/components/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/benches/benches/bevy_ecs/components/mod.rs b/benches/benches/bevy_ecs/components/mod.rs index aec44ed27c9d0..7a68f97fd4e67 100644 --- a/benches/benches/bevy_ecs/components/mod.rs +++ b/benches/benches/bevy_ecs/components/mod.rs @@ -7,8 +7,10 @@ mod add_remove_very_big_table; mod archetype_updates; mod insert_simple; mod insert_simple_unbatched; +mod component_constraints; use archetype_updates::*; +use component_constraints::*; use criterion::{criterion_group, Criterion}; criterion_group!( @@ -19,6 +21,11 @@ criterion_group!( insert_simple, no_archetypes, added_archetypes, + spawn_no_constraint, + spawn_with_simple_constraint, + spawn_with_complex_constraint, + spawn_chain_10_constraint, + spawn_chain_10_no_constraint, ); fn add_remove(c: &mut Criterion) { From 93e793cfc5b67e1d553553eac894dc497b74ba1d Mon Sep 17 00:00:00 2001 From: Shaopeng Luan Date: Sat, 4 Apr 2026 21:32:14 +1100 Subject: [PATCH 5/7] chore: tests. ci and docs --- benches/benches/bevy_ecs/components/mod.rs | 2 +- crates/bevy_ecs/macros/src/component.rs | 61 +++++++++++++-------- crates/bevy_ecs/macros/src/lib.rs | 9 ++- crates/bevy_ecs/src/bundle/insert.rs | 5 +- crates/bevy_ecs/src/component/constraint.rs | 24 +++++++- crates/bevy_ecs/src/component/info.rs | 3 + crates/bevy_ecs/src/query/access.rs | 2 +- crates/bevy_ecs/src/query/state.rs | 3 + examples/README.md | 1 + examples/ecs/component_constraints.rs | 7 ++- 10 files changed, 84 insertions(+), 33 deletions(-) diff --git a/benches/benches/bevy_ecs/components/mod.rs b/benches/benches/bevy_ecs/components/mod.rs index 7a68f97fd4e67..02a9625e657db 100644 --- a/benches/benches/bevy_ecs/components/mod.rs +++ b/benches/benches/bevy_ecs/components/mod.rs @@ -5,9 +5,9 @@ mod add_remove_sparse_set; mod add_remove_table; mod add_remove_very_big_table; mod archetype_updates; +mod component_constraints; mod insert_simple; mod insert_simple_unbatched; -mod component_constraints; use archetype_updates::*; use component_constraints::*; diff --git a/crates/bevy_ecs/macros/src/component.rs b/crates/bevy_ecs/macros/src/component.rs index 5341c2b33cf78..6e455228bffcc 100644 --- a/crates/bevy_ecs/macros/src/component.rs +++ b/crates/bevy_ecs/macros/src/component.rs @@ -326,19 +326,27 @@ pub fn derive_component(input: TokenStream) -> TokenStream { let has_constraint = attrs.constraint_expr.is_some() || attrs.constraint_only.is_some(); let register_constraint = has_constraint.then(|| { - let dnf_expr = attrs.constraint_expr.as_ref().map(|ast| { - let transformed = constraint_to_tokens(ast, &bevy_ecs_path); - quote! { Some(#transformed) } - }).unwrap_or_else(|| quote! { None }); - - let only_expr = attrs.constraint_only.as_ref().map(|paths| { - quote! {{ - let ids: &[#bevy_ecs_path::component::ComponentId] = &[ - #( _registrator.register_component::<#paths>(), )* - ]; - Some(ids.to_vec()) - }} - }).unwrap_or_else(|| quote! { None }); + let dnf_expr = attrs + .constraint_expr + .as_ref() + .map(|ast| { + let transformed = constraint_to_tokens(ast, &bevy_ecs_path); + quote! { Some(#transformed) } + }) + .unwrap_or_else(|| quote! { None }); + + let only_expr = attrs + .constraint_only + .as_ref() + .map(|paths| { + quote! {{ + let ids: &[#bevy_ecs_path::component::ComponentId] = &[ + #( _registrator.register_component::<#paths>(), )* + ]; + Some(ids.to_vec()) + }} + }) + .unwrap_or_else(|| quote! { None }); quote! { fn register_constraint( @@ -352,7 +360,6 @@ pub fn derive_component(input: TokenStream) -> TokenStream { } }); - // This puts `register_required` before `register_recursive_requires` to ensure that the constructors of _all_ top // level components are initialized first, giving them precedence over recursively defined constructors for the same component type TokenStream::from(quote! { @@ -396,22 +403,28 @@ fn constraint_to_tokens(ast: &ComponentConstraintAst, bevy_ecs_path: &Path) -> T match ast { ComponentConstraintAst::Require(path) => { quote! { #bevy_ecs_path::component::require(_registrator.register_component::<#path>()) } - }, + } ComponentConstraintAst::Forbid(path) => { quote! { #bevy_ecs_path::component::forbid(_registrator.register_component::<#path>()) } - }, + } ComponentConstraintAst::Not(inner) => { let inner = constraint_to_tokens(inner, bevy_ecs_path); quote! { #bevy_ecs_path::component::not(#inner) } - }, + } ComponentConstraintAst::And(asts) => { - let items: Vec = asts.iter().map(|i| constraint_to_tokens(i, bevy_ecs_path)).collect(); + let items: Vec = asts + .iter() + .map(|i| constraint_to_tokens(i, bevy_ecs_path)) + .collect(); quote! { #bevy_ecs_path::component::and([#(#items),*]) } - }, + } ComponentConstraintAst::Or(asts) => { - let items: Vec = asts.iter().map(|i| constraint_to_tokens(i, bevy_ecs_path)).collect(); + let items: Vec = asts + .iter() + .map(|i| constraint_to_tokens(i, bevy_ecs_path)) + .collect(); quote! { #bevy_ecs_path::component::or([#(#items),*]) } - }, + } } } @@ -649,7 +662,7 @@ struct Attrs { clone_behavior: Option, map_entities: Option, constraint_expr: Option, - constraint_only: Option> + constraint_only: Option>, } #[derive(Clone, Copy)] @@ -961,7 +974,7 @@ impl Parse for RelationshipTarget { impl Parse for ComponentConstraintAst { fn parse(input: syn::parse::ParseStream) -> Result { - let ident: Ident = input.parse()?; + let ident: Ident = input.parse()?; let content; parenthesized!(content in input); @@ -993,7 +1006,7 @@ impl Parse for ComponentConstraintAst { )) } _ => Err(syn::Error::new( - ident.span(), + ident.span(), format!("unknown constraint: `{}`, expected one of: require, forbid, not, and, or, only", ident) )) } diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 65a7ad0baaa2a..41672938ef1ed 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -713,7 +713,14 @@ pub fn derive_settings_group(input: TokenStream) -> TokenStream { /// ``` #[proc_macro_derive( Component, - attributes(component, require, constraint, relationship, relationship_target, entities) + attributes( + component, + require, + constraint, + relationship, + relationship_target, + entities + ) )] pub fn derive_component(input: TokenStream) -> TokenStream { component::derive_component(input) diff --git a/crates/bevy_ecs/src/bundle/insert.rs b/crates/bevy_ecs/src/bundle/insert.rs index fee2aea0761da..3180037815606 100644 --- a/crates/bevy_ecs/src/bundle/insert.rs +++ b/crates/bevy_ecs/src/bundle/insert.rs @@ -61,14 +61,15 @@ impl<'w> BundleInserter<'w> { let bundle_info = world.bundles.get_unchecked(bundle_id); let bundle_id = bundle_info.id(); - // This is a good place to trigger the [`ComponentsContraintError`] event, but the upper layer still needs to write Components + // This is a good place to trigger the [`ComponentsConstraintError`] event, but the upper layer still needs to write Components + // We chose to keep propagating it when violating. let (new_archetype_id, is_new_created) = bundle_info.insert_bundle_into_archetype( &mut world.archetypes, &mut world.storages, &world.components, &world.observers, archetype_id, - )?; // We chose to keep propagating it when violating. + )?; // TODO: trigger an event diff --git a/crates/bevy_ecs/src/component/constraint.rs b/crates/bevy_ecs/src/component/constraint.rs index 08bdc52d8f723..d40e0e59e0819 100644 --- a/crates/bevy_ecs/src/component/constraint.rs +++ b/crates/bevy_ecs/src/component/constraint.rs @@ -9,7 +9,7 @@ use fixedbitset::FixedBitSet; use super::ComponentId; -/// [`ComponentConstraint`] stored in [`ComponentInfo`] +/// [`ComponentConstraint`] stored in `ComponentInfo` #[derive(Debug, Clone)] pub struct ComponentConstraint { /// Compiled DNF form @@ -472,4 +472,26 @@ mod tests { let e = world.spawn(Caster).id(); assert!(!world.entity(e).contains::()); } + + #[test] + fn ghost_entity_can_recover() { + let mut world = World::new(); + let e = world.spawn(Player).id(); + assert!(!world.entity(e).contains::()); + + world.entity_mut(e).insert((Player, Health)); + assert!(world.entity(e).contains::()); + assert!(world.entity(e).contains::()); + } + + #[test] + fn ghost_entity_can_despawn() { + let mut world = World::new(); + let e = world.spawn(Player).id(); + assert!(!world.entity(e).contains::()); + + // despawn should work + world.despawn(e); + assert!(world.get_entity(e).is_err()); + } } diff --git a/crates/bevy_ecs/src/component/info.rs b/crates/bevy_ecs/src/component/info.rs index ba205f1ad99e7..5464a7eade2d8 100644 --- a/crates/bevy_ecs/src/component/info.rs +++ b/crates/bevy_ecs/src/component/info.rs @@ -368,6 +368,9 @@ pub struct Components { pub(super) indices: TypeIdMap, // This is kept internal and local to verify that no deadlocks can occur. pub(super) queued: bevy_platform::sync::RwLock, + // ComponentId <-> Exclusion BitSet with all other components + // solve SAT problem for all components pair in registration phase + // exclusions: Vec>, } impl Components { diff --git a/crates/bevy_ecs/src/query/access.rs b/crates/bevy_ecs/src/query/access.rs index e2d636aad7c2d..34ee868c9e954 100644 --- a/crates/bevy_ecs/src/query/access.rs +++ b/crates/bevy_ecs/src/query/access.rs @@ -1050,7 +1050,7 @@ impl FilteredAccess { #[derive(Eq, PartialEq, Default, Debug)] pub struct AccessFilters { pub(crate) with: ComponentIdSet, - pub(crate) without: ComponentIdSet, + pub(crate) without: ComponentIdSet, // TODO: extend it, just union from `exclusions` in [`Components`] } // This is needed since `#[derive(Clone)]` does not generate optimized `clone_from`. diff --git a/crates/bevy_ecs/src/query/state.rs b/crates/bevy_ecs/src/query/state.rs index 11c11998a8cc3..0cd7782c9daf5 100644 --- a/crates/bevy_ecs/src/query/state.rs +++ b/crates/bevy_ecs/src/query/state.rs @@ -269,6 +269,9 @@ impl QueryState { let mut filter_component_access = FilteredAccess::default(); F::update_component_access(&filter_state, &mut filter_component_access); + // TODO: A good place, extend `filter_component_access.filter_sets.without` base on exclusions in Components + // i.e. extend the [`Without`] to [`(Without, Without)`] + // Merge the temporary filter access with the main access. This ensures that filter access is // properly considered in a global "cross-query" context (both within systems and across systems). component_access.extend(&filter_component_access); diff --git a/examples/README.md b/examples/README.md index 399e7de23ed02..3e82b9584de1b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -327,6 +327,7 @@ Example | Description --- | --- [Callbacks](../examples/ecs/callbacks.rs) | Store arbitrary systems in components and run them on demand [Change Detection](../examples/ecs/change_detection.rs) | Change detection on components and resources +[Component Constraints](../examples/ecs/component_constraints.rs) | Declare constraints on component relationships using require, forbid, and, or, not, and only [Component Hooks](../examples/ecs/component_hooks.rs) | Define component hooks to manage component lifecycle events [Contiguous Query](../examples/ecs/contiguous_query.rs) | Demonstrates contiguous queries [Custom Executor](../examples/ecs/custom_executor.rs) | Demonstrates how to make a custom SystemExecutor diff --git a/examples/ecs/component_constraints.rs b/examples/ecs/component_constraints.rs index 4afb6ecd878bf..96ef0b6720092 100644 --- a/examples/ecs/component_constraints.rs +++ b/examples/ecs/component_constraints.rs @@ -12,9 +12,10 @@ //! - `not(...)`: negates a sub-constraint //! - `only(T1, T2, ...)`: only these components (plus self) are allowed -use bevy::{log::{self, LogPlugin}, prelude::*}; - -// --- Component definitions with constraints --- +use bevy::{ + log::{self, LogPlugin}, + prelude::*, +}; #[derive(Component, Default, Debug)] struct Health(i32); From b8dc9434f9316ad8c6dc22370ccf3b95a12dc0a6 Mon Sep 17 00:00:00 2001 From: Shaopeng Luan Date: Sat, 4 Apr 2026 21:48:11 +1100 Subject: [PATCH 6/7] fix: continue fix code warning --- crates/bevy_ecs/src/component/constraint.rs | 2 +- examples/ecs/component_constraints.rs | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/bevy_ecs/src/component/constraint.rs b/crates/bevy_ecs/src/component/constraint.rs index d40e0e59e0819..0821356411b3d 100644 --- a/crates/bevy_ecs/src/component/constraint.rs +++ b/crates/bevy_ecs/src/component/constraint.rs @@ -201,7 +201,7 @@ impl Dnf { } } -/// Convert a [`Constraint`] tree into a list of [`DnfClause`]s. +/// Convert a [`ConstraintExpr`] tree into a list of [`DnfClause`]s. fn to_dnf_clauses(constraint: &ConstraintExpr) -> Vec { match constraint { ConstraintExpr::Required(id) => { diff --git a/examples/ecs/component_constraints.rs b/examples/ecs/component_constraints.rs index 96ef0b6720092..0e08db4cc4be9 100644 --- a/examples/ecs/component_constraints.rs +++ b/examples/ecs/component_constraints.rs @@ -18,13 +18,13 @@ use bevy::{ }; #[derive(Component, Default, Debug)] -struct Health(i32); +struct Health; #[derive(Component, Default, Debug)] -struct Mana(i32); +struct Mana; #[derive(Component, Default, Debug)] -struct Armor(i32); +struct Armor; #[derive(Component, Default, Debug)] struct Enemy; @@ -71,8 +71,8 @@ fn demo(mut commands: Commands, mut exit: MessageWriter) { log::info!("\n=== require constraint ==="); // OK: Player + Health satisfies require(Health) - commands.spawn((Player, Health(100))); - log::info!("Spawning Player + Health(100)..."); + commands.spawn((Player, Health)); + log::info!("Spawning Player + Health..."); // FAIL: Player alone - missing Health commands.spawn(Player); @@ -91,7 +91,7 @@ fn demo(mut commands: Commands, mut exit: MessageWriter) { log::info!("\n=== or constraint ==="); // OK: Caster + Mana - commands.spawn((Caster, Mana(50))); + commands.spawn((Caster, Mana)); log::info!("Spawning Caster + Mana..."); // OK: Caster + Scroll @@ -105,17 +105,17 @@ fn demo(mut commands: Commands, mut exit: MessageWriter) { log::info!("\n=== only constraint ==="); // OK: Warrior + Health + Armor - all in whitelist - commands.spawn((Warrior, Health(80), Armor(20))); + commands.spawn((Warrior, Health, Armor)); log::info!("Spawning Warrior + Health + Armor..."); // FAIL: Warrior + Health + Enemy - Enemy not in whitelist - commands.spawn((Warrior, Health(80), Enemy)); + commands.spawn((Warrior, Health, Enemy)); log::info!("Spawning Warrior + Health + Enemy (should be rejected)..."); log::info!("\n=== only + require combined ==="); // OK: Knight + Health - commands.spawn((Knight, Health(100))); + commands.spawn((Knight, Health)); log::info!("Spawning Knight + Health..."); // FAIL: Knight alone - missing required Health @@ -123,7 +123,7 @@ fn demo(mut commands: Commands, mut exit: MessageWriter) { log::info!("Spawning Knight alone (should be rejected)..."); // FAIL: Knight + Health + Enemy - Enemy violates only - commands.spawn((Knight, Health(100), Enemy)); + commands.spawn((Knight, Health, Enemy)); log::info!("Spawning Knight + Health + Enemy (should be rejected)..."); exit.write(AppExit::Success); From 873daa2a4a077658dc886269de987dfcf6fb1ad0 Mon Sep 17 00:00:00 2001 From: Shaopeng Luan Date: Mon, 6 Apr 2026 09:02:11 +1000 Subject: [PATCH 7/7] fix: hidden edge case detected --- crates/bevy_ecs/src/component/constraint.rs | 25 +++++++ crates/bevy_ecs/src/world/mod.rs | 79 ++++++++++++++------- 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/crates/bevy_ecs/src/component/constraint.rs b/crates/bevy_ecs/src/component/constraint.rs index 0821356411b3d..f6a157cf70acd 100644 --- a/crates/bevy_ecs/src/component/constraint.rs +++ b/crates/bevy_ecs/src/component/constraint.rs @@ -494,4 +494,29 @@ mod tests { world.despawn(e); assert!(world.get_entity(e).is_err()); } + + #[derive(Component, Default)] + #[constraint(only(BatchB))] + struct BatchA; + + #[derive(Component, Default)] + struct BatchB; + + #[derive(Component, Default)] + struct BatchC; + + #[test] + fn batch_insert_mixed_archetypes_expect_panic() { + let mut world = World::new(); + + let a = world.spawn((BatchA, BatchB)).id(); + let b = world.spawn((BatchB, BatchC)).id(); + let c = world.spawn((BatchA, BatchB)).id(); + + // TODO: `insert_batch`` will fail totally because the first validation is fail + let _ = world.try_insert_batch([(a, Health), (b, Health), (c, Health)]); + assert!(!world.entity(a).contains::(), "a should not have Health"); + assert!(world.entity(b).contains::(), "b should have Health"); + assert!(!world.entity(c).contains::(), "c also should have Health"); + } } diff --git a/crates/bevy_ecs/src/world/mod.rs b/crates/bevy_ecs/src/world/mod.rs index ecedce2569b5b..f85b8837dd8b9 100644 --- a/crates/bevy_ecs/src/world/mod.rs +++ b/crates/bevy_ecs/src/world/mod.rs @@ -2498,6 +2498,8 @@ impl World { change_tick, ) }) else { + // TODO: if the first entity validation failed, just return is not good. Should find the first entity that is validated. + // Maybe just return? corresponding to `try_insert_batch_with_caller` return; }; let mut cache = InserterArchetypeCache { @@ -2525,19 +2527,32 @@ impl World { } }; if location.archetype_id != cache.archetype_id { - cache = InserterArchetypeCache { - // SAFETY: we initialized this bundle_id in `register_info`. - // Constraint was already validated when the first inserter was created. - inserter: unsafe { - BundleInserter::new_with_id( - self, - location.archetype_id, - bundle_id, - change_tick, - ) - .expect("constraint already validated for this bundle type, THIS SHOULD NOT HAPPEN") + let new_archetype_id = location.archetype_id; + let old_archetype_id = cache.archetype_id; + + // SAFETY: we initialized this bundle_id in `register_info`. + cache = match unsafe { + BundleInserter::new_with_id(self, new_archetype_id, bundle_id, change_tick) + } { + Ok(inserter) => InserterArchetypeCache { + inserter, + archetype_id: new_archetype_id }, - archetype_id: location.archetype_id, + Err(_) => { + InserterArchetypeCache { + // SAFETY: we are rebuilding the previous inserter. + inserter: unsafe { + BundleInserter::new_with_id(self, old_archetype_id, bundle_id, change_tick) + .expect("rebuilding previous inserter that was already valid, THIS SHOULD NOT HAPPEN") + }, + archetype_id: old_archetype_id + } + } + }; + + if cache.archetype_id != new_archetype_id { + // Jump over this invalidate archetype + continue; } } move_as_ptr!(bundle); @@ -2650,7 +2665,8 @@ impl World { change_tick, ) }) else { - break None; + // Validation failed + continue; }; let mut cache = InserterArchetypeCache { inserter: first_inserter, @@ -2688,20 +2704,33 @@ impl World { continue; }; if location.archetype_id != cache.archetype_id { - cache = InserterArchetypeCache { - // SAFETY: we initialized this bundle_id in `register_info`. - // Constraint was already validated when the first inserter was created. - inserter: unsafe { - BundleInserter::new_with_id( - self, - location.archetype_id, - bundle_id, - change_tick, - ) - .expect("constraint already validated for this bundle type, THIS SHOULD NOT HAPPEN") + let new_archetype_id = location.archetype_id; + let old_archetype_id = cache.archetype_id; + + // SAFETY: we initialized this bundle_id in `register_info`. + cache = match unsafe { + BundleInserter::new_with_id(self, new_archetype_id, bundle_id, change_tick) + } { + Ok(inserter) => InserterArchetypeCache { + inserter, + archetype_id: new_archetype_id }, - archetype_id: location.archetype_id, + Err(_) => { + InserterArchetypeCache { + // SAFETY: we are rebuilding the previous inserter. + inserter: unsafe { + BundleInserter::new_with_id(self, old_archetype_id, bundle_id, change_tick) + .expect("rebuilding previous inserter that was already valid, THIS SHOULD NOT HAPPEN") + }, + archetype_id: old_archetype_id + } + } }; + + if cache.archetype_id != new_archetype_id { + // Jump over this invalidate archetype + continue; + } } move_as_ptr!(bundle);