From 222b75d2673d8fd92549a0f09ea588402dcaf82b Mon Sep 17 00:00:00 2001 From: makspll Date: Thu, 22 Oct 2026 13:15:33 +0000 Subject: [PATCH 1/6] feat: read only script system parameters - `resource_read` / `component_read` declare read access to bevy and to the guard, `resource` / `component` stay read and write - the guard's AccessBitField gets a write forbidden bit (count: 5 bits) and claims fail at the max count instead of overflowing to 0 - fix: query components were declared to bevy as reads while scripts could write them, resources as accessing nothing (never conflicting) - fix: get_all_access_ids reported every access as a write --- ...read_only_parameters_cannot_be_written.lua | 52 +++++++ .../bevy_mod_scripting_bindings/src/query.rs | 49 +++++-- .../src/world_extensions.rs | 2 +- .../bevy_mod_scripting_core/src/extractors.rs | 10 +- .../src/script_system.rs | 128 +++++++++++++++--- .../bevy_mod_scripting_functions/src/core.rs | 38 ++++++ .../src/access_map.rs | 90 ++++++++++-- docs/src/ReleaseNotes/0.23-to-0.24.md | 2 + docs/src/ScriptSystems/introduction.md | 8 ++ 9 files changed, 334 insertions(+), 45 deletions(-) create mode 100644 assets/tests/add_system/read_only_parameters_cannot_be_written.lua diff --git a/assets/tests/add_system/read_only_parameters_cannot_be_written.lua b/assets/tests/add_system/read_only_parameters_cannot_be_written.lua new file mode 100644 index 0000000000..eae6e7f619 --- /dev/null +++ b/assets/tests/add_system/read_only_parameters_cannot_be_written.lua @@ -0,0 +1,52 @@ +-- parameters declared with `resource_read` / `component_read` are declared as reads to bevy, +-- so the system may read but must not write them +local ScriptAttachment = import_namespace("bms").ScriptAttachment +local bms = import_namespace("bms") +local world = bms.world +runs = {} +local ResourceType = world.get_type_by_name("test_utils::test_data::TestResourceWithVariousFields") +local ComponentA = world.get_type_by_name("test_utils::test_data::CompWithFromWorldAndComponentData") + +function on_test() + local post_update_schedule = world.get_schedule_by_name("PostUpdate") + local script_attachment = ScriptAttachment.new_entity_script(bms.current_entity(), bms.current_script()) + + local entity = world.spawn() + world.add_default_component(entity, ComponentA) + + world.add_system( + post_update_schedule, + bms.system_builder("my_reading_system", script_attachment) + :resource_read(ResourceType) + :query(world.query():component_read(ComponentA)) + ) + + return true +end + +function my_reading_system(resource, query) + runs[#runs + 1] = "my_reading_system" + local ok, err = pcall(check_accesses, resource, query) + runs[#runs + 1] = ok and "finished" or tostring(err) +end + +function check_accesses(resource, query) + assert(resource.string == "Initial Value", "Expected 'Initial Value', got: " .. resource.string) + assert(#query >= 1, "Expected results, got: " .. #query) + local component = query[1]:components()[1] + assert(type(component[1]) == "string", "Expected to read the component, got: " .. tostring(component[1])) + + local ok, err = pcall(function() resource.string = "changed" end) + assert(not ok and tostring(err):find("annot claim access"), "resource write: " .. tostring(err)) + ok, err = pcall(function() component[1] = "changed" end) + assert(not ok and tostring(err):find("annot claim access"), "component write: " .. tostring(err)) +end + +function on_test_post_update() + return true +end + +function on_test_last() + assert(#runs == 2 and runs[2] == "finished", "the system did not finish, runs: " .. table.concat(runs, ",")) + return true +end diff --git a/crates/bevy_mod_scripting_bindings/src/query.rs b/crates/bevy_mod_scripting_bindings/src/query.rs index 3519302b79..c31d3c1df0 100644 --- a/crates/bevy_mod_scripting_bindings/src/query.rs +++ b/crates/bevy_mod_scripting_bindings/src/query.rs @@ -431,6 +431,8 @@ impl std::fmt::Display for ScriptTypeRegistration { pub struct ScriptQueryBuilder { /// The components to query for. pub components: Vec, + /// Whether each of the components is written, components without an entry are written. + writes: Vec, /// Components that must be present. pub with: Vec, /// Components that must not be present. @@ -444,17 +446,38 @@ impl ScriptQueryBuilder { Default::default() } - /// Adds components to the query. + /// Adds components to the query, which can be read and written. pub fn components(&mut self, components: Vec) -> &mut Self { - self.components.extend(components); + for component in components { + self.component(component); + } self } - /// Adds a component to the query. + /// Adds a component to the query, which can be read and written. pub fn component(&mut self, component: ScriptComponentRegistration) -> &mut Self { + self.push_component(component, true) + } + + /// Adds a component to the query, which can only be read. + /// + /// Systems reading a component can run in parallel with each other. + pub fn component_read(&mut self, component: ScriptComponentRegistration) -> &mut Self { + self.push_component(component, false) + } + + fn push_component(&mut self, component: ScriptComponentRegistration, write: bool) -> &mut Self { + // components pushed to the public field directly are written + self.writes.resize(self.components.len(), true); self.components.push(component); + self.writes.push(write); self } + /// Returns true if the component at the given index of [`Self::components`] is written + pub fn is_written(&self, index: usize) -> bool { + self.writes.get(index).copied().unwrap_or(true) + } + /// Adds components to the query that must be present. pub fn with_components(&mut self, with: Vec) -> &mut Self { self.with.extend(with); @@ -480,13 +503,23 @@ impl ScriptQueryBuilder { } /// Builds the query into a query state as used in systems. - pub fn as_query_state(&self, world: &mut World) -> QueryState { + /// + /// With `declare_writes`, the written components are declared as written in the query's access, as needed when the + /// query's access is reported to the scheduler. Otherwise all components are declared as read. + pub fn as_query_state( + &self, + world: &mut World, + declare_writes: bool, + ) -> QueryState { let mut dynamic_query = QueryBuilder::::new(world); // we don't actually want to fetch the data for components now, only figure out - // which entities match the query - // so we might be being slightly overkill - for c in &self.components { - dynamic_query.ref_id(c.component_id()); + // which entities match the query, and declare the access + for (index, c) in self.components.iter().enumerate() { + if declare_writes && self.is_written(index) { + dynamic_query.mut_id(c.component_id()); + } else { + dynamic_query.ref_id(c.component_id()); + } } for w in &self.with { diff --git a/crates/bevy_mod_scripting_bindings/src/world_extensions.rs b/crates/bevy_mod_scripting_bindings/src/world_extensions.rs index cb397817cf..9d62ed71fb 100644 --- a/crates/bevy_mod_scripting_bindings/src/world_extensions.rs +++ b/crates/bevy_mod_scripting_bindings/src/world_extensions.rs @@ -509,7 +509,7 @@ impl<'w> WorldExtensions for WorldAccessGuard<'w> { query: crate::ScriptQueryBuilder, ) -> Result, InteropError> { self.with_world_mut(|world| { - let mut built_query = query.as_query_state::(world); + let mut built_query = query.as_query_state::(world, false); let query_result = built_query.iter(world); Ok(query_result diff --git a/crates/bevy_mod_scripting_core/src/extractors.rs b/crates/bevy_mod_scripting_core/src/extractors.rs index 116ef0da9f..75063ca1d7 100644 --- a/crates/bevy_mod_scripting_core/src/extractors.rs +++ b/crates/bevy_mod_scripting_core/src/extractors.rs @@ -143,12 +143,12 @@ pub(crate) fn get_all_access_ids(access: &Access) -> Vec<(ComponentId, bool)> { let mut access_all_write = Access::default(); access_all_write.write_all(); - // read conflicts with each set to figure out the necessary locks + // read conflicts with each set to figure out the necessary locks: + // only writes conflict with reading everything, reads and writes conflict with writing everything + let written = individual_conflicts(access.get_conflicts(&access_all_read)); + let mut read = individual_conflicts(access.get_conflicts(&access_all_write)); - let mut read = individual_conflicts(access.get_conflicts(&access_all_read)); - let written = individual_conflicts(access.get_conflicts(&access_all_write)); - - // remove reads from writes + // remove writes from reads read.difference_with(&written); let mut result = Vec::new(); diff --git a/crates/bevy_mod_scripting_core/src/script_system.rs b/crates/bevy_mod_scripting_core/src/script_system.rs index 6d473ddff9..3be371349f 100644 --- a/crates/bevy_mod_scripting_core/src/script_system.rs +++ b/crates/bevy_mod_scripting_core/src/script_system.rs @@ -14,7 +14,7 @@ use ::{ bevy_ecs::{ component::ComponentId, entity::Entity, - query::{FilteredAccess, FilteredAccessSet, QueryState}, + query::{FilteredAccessSet, QueryState}, reflect::AppTypeRegistry, schedule::SystemSet, system::{System, SystemParamValidationError}, @@ -89,7 +89,10 @@ impl SystemSet for ScriptSystemSet { #[derive(Clone)] enum ScriptSystemParamDescriptor { - Res(ScriptResourceRegistration), + Res { + resource: ScriptResourceRegistration, + write: bool, + }, EntityQuery(ScriptQueryBuilder), } @@ -126,10 +129,21 @@ impl ScriptSystemBuilder { self } - /// Adds a resource access to the system + /// Adds a resource access to the system, the resource can be read and written pub fn resource(&mut self, resource: ScriptResourceRegistration) -> &mut Self { - self.system_params - .push(ScriptSystemParamDescriptor::Res(resource)); + self.system_params.push(ScriptSystemParamDescriptor::Res { + resource, + write: true, + }); + self + } + + /// Adds a read only resource access to the system, systems reading a resource can run in parallel with each other + pub fn resource_read(&mut self, resource: ScriptResourceRegistration) -> &mut Self { + self.system_params.push(ScriptSystemParamDescriptor::Res { + resource, + write: false, + }); self } @@ -847,12 +861,17 @@ impl System for DynamicScriptSystem

{ // start with resources let mut subset = HashSet::::new(); + // the accesses in `subset` which can be written, the others are read only + let mut written = HashSet::::new(); let mut system_params = Vec::with_capacity(self.system_param_descriptors.len()); let mut component_access_set = FilteredAccessSet::new(); let mut initialization_errors = Vec::new(); for param in &self.system_param_descriptors { match param { - ScriptSystemParamDescriptor::Res(res) => { + ScriptSystemParamDescriptor::Res { + resource: res, + write, + } => { let component_id = res.resource_id; let type_id = res.type_registration().type_id(); @@ -862,10 +881,13 @@ impl System for DynamicScriptSystem

{ }; system_params.push(system_param); - let mut access = FilteredAccess::matches_nothing(); - - access.add_write(component_id); - component_access_set.add(access); + // like `Res`/`ResMut`, an access matching nothing would be compatible with any other access + if *write { + component_access_set.add_resource_write(component_id); + written.insert(component_id); + } else { + component_access_set.add_resource_read(component_id); + } if subset.contains(&component_id) { initialization_errors.push(format!( "Duplicate resource access in system: {component_id:?}." @@ -879,7 +901,7 @@ impl System for DynamicScriptSystem

{ .iter() .map(|c| (c.component_id, c.type_registration().type_id())) .collect(); - let query = query.as_query_state::(world); + let query = query.as_query_state::(world, true); // The access carries the query filters, so bevy runs this system in parallel with systems accessing // the same components on other archetypes (i.e. `With` vs `Without`). @@ -887,10 +909,14 @@ impl System for DynamicScriptSystem

{ // matched by this query in `run_unsafe`, see `ArchetypeFilter`. component_access_set.add(query.component_access().clone()); - let new_raids = get_all_access_ids(query.component_access().access()) - .into_iter() - .map(|(a, _)| a) - .collect::>(); + let accesses = get_all_access_ids(query.component_access().access()); + written.extend( + accesses + .iter() + .filter(|(_, write)| *write) + .map(|(id, _)| *id), + ); + let new_raids = accesses.into_iter().map(|(a, _)| a).collect::>(); if !subset.is_disjoint(&new_raids) { initialization_errors @@ -906,8 +932,15 @@ impl System for DynamicScriptSystem

{ } } - let final_subset = - AccessByteSet::from_allowed_list(&subset.iter().map(|c| c.index()).collect::>()); + // the guard must not allow writes which are declared to bevy as reads + let (read_write, read_only): (Vec<_>, Vec<_>) = subset + .iter() + .map(|c| (c.index(), written.contains(c))) + .partition(|(_, write)| *write); + let final_subset = AccessByteSet::from_allowed_lists( + &read_write.into_iter().map(|(c, _)| c).collect::>(), + &read_only.into_iter().map(|(c, _)| c).collect::>(), + ); self.state = Some(ScriptSystemState { type_registry: world.get_resource_or_init::().clone().0, @@ -1077,7 +1110,9 @@ mod test { schedule::{ScheduleLabel, Schedules}, }, }; - use bevy_mod_scripting_bindings::ScriptValue; + use bevy_mod_scripting_bindings::{ + ScriptComponentRegistration, ScriptTypeRegistration, ScriptValue, + }; use test_utils::make_test_plugin; use crate::{ @@ -1291,4 +1326,61 @@ mod test { 1 ); } + + #[derive(bevy_ecs::resource::Resource, Reflect, Default)] + struct AccessTestResource; + + #[derive(bevy_ecs::component::Component, Reflect)] + struct AccessTestComponent; + + fn access_of(world: &mut World, f: impl FnOnce(&mut ScriptSystemBuilder)) -> FilteredAccessSet { + let mut builder = ScriptSystemBuilder::new("test".to_owned().into(), attachment()); + f(&mut builder); + let mut system = DynamicScriptSystem::::new( + builder, + ScriptSystemSet(0), + Arc::new(AtomicBool::new(true)), + ScriptSystems::default(), + ); + match system.initialize(world) { + SystemAccess::Shared(access) => access, + _ => panic!("expected a non-exclusive system"), + } + } + + #[test] + fn read_only_parameters_are_declared_as_reads() { + let mut world = World::new(); + let resource = ScriptResourceRegistration::new( + ScriptTypeRegistration::new(Arc::new(bevy_reflect::TypeRegistration::of::< + AccessTestResource, + >())), + world.init_resource::(), + ); + let component = ScriptComponentRegistration::new( + ScriptTypeRegistration::new(Arc::new(bevy_reflect::TypeRegistration::of::< + AccessTestComponent, + >())), + world.register_component::(), + ); + let reader = |builder: &mut ScriptSystemBuilder| { + let mut query = ScriptQueryBuilder::new(); + query.component_read(component.clone()); + builder.resource_read(resource.clone()).query(query); + }; + let resource_writer = |builder: &mut ScriptSystemBuilder| { + builder.resource(resource.clone()); + }; + let component_writer = |builder: &mut ScriptSystemBuilder| { + let mut query = ScriptQueryBuilder::new(); + query.component(component.clone()); + builder.query(query); + }; + + let a = access_of(&mut world, reader); + let b = access_of(&mut world, reader); + assert!(a.is_compatible(&b), "readers should run in parallel"); + assert!(!a.is_compatible(&access_of(&mut world, resource_writer))); + assert!(!a.is_compatible(&access_of(&mut world, component_writer))); + } } diff --git a/crates/bevy_mod_scripting_functions/src/core.rs b/crates/bevy_mod_scripting_functions/src/core.rs index 1896946cdd..c7c47f018f 100644 --- a/crates/bevy_mod_scripting_functions/src/core.rs +++ b/crates/bevy_mod_scripting_functions/src/core.rs @@ -1401,6 +1401,25 @@ impl ScriptQueryBuilder { V(builder) } + /// Adds a component to be retrieved by the query, which can only be read. + /// + /// Script systems reading a component can run in parallel with each other, unlike ones writing it. + /// + /// Arguments: + /// * `query`: The query to add the component to + /// * `component`: The component to add + /// Returns: + /// * `query`: The query with the component added + fn component_read( + query: V, + component: V, + ) -> V { + profiling::function_scope!("component_read"); + let mut builder = query.into_inner(); + builder.component_read(component.into_inner()); + V(builder) + } + /// Adds a component to filter the query by. This component will NOT be retrieved. /// /// Arguments: @@ -1630,6 +1649,25 @@ impl ScriptSystemBuilder { builder.into() } + /// Requests the system have read only access to the given resource. The resource will be added to the + /// list of arguments of the callback in the order they're provided. + /// + /// Script systems reading a resource can run in parallel with each other, unlike ones writing it. + /// Arguments: + /// * `builder`: The system builder to add the resource to. + /// * `resource`: The resource to add. + /// Returns: + /// * `builder`: The system builder with the resource added. + fn resource_read( + builder: V, + resource: V, + ) -> V { + profiling::function_scope!("resource_read"); + let mut builder = builder.into_inner(); + builder.resource_read(resource.into_inner()); + builder.into() + } + /// Specifies the system is to run exclusively, meaning it can access anything, but will not run in parallel with other systems. /// Arguments: /// * `builder`: The system builder to make exclusive. diff --git a/crates/bevy_mod_scripting_world/src/access_map.rs b/crates/bevy_mod_scripting_world/src/access_map.rs index ebb61d7435..4c99685abc 100644 --- a/crates/bevy_mod_scripting_world/src/access_map.rs +++ b/crates/bevy_mod_scripting_world/src/access_map.rs @@ -87,9 +87,12 @@ pub struct AccessBitField(u8); impl AccessBitField { const KIND_MASK: u8 = 0b0000_0001; const FORBIDDEN_MASK: u8 = 0b0000_0010; - const COUNT_MASK: u8 = 0b1111_1100; - const COUNT_SHIFT: u8 = 2; - const MAX_COUNT: u8 = 0b0011_1111; // 63 + const WRITE_FORBIDDEN_MASK: u8 = 0b0000_0100; + /// The permissions, which are kept when accesses are claimed and released + const PERMISSION_MASK: u8 = Self::FORBIDDEN_MASK | Self::WRITE_FORBIDDEN_MASK; + const COUNT_MASK: u8 = 0b1111_1000; + const COUNT_SHIFT: u8 = 3; + const MAX_COUNT: u8 = 0b0001_1111; // 31 /// Claims the given access and returns an adjusted state pub fn claim_if_free(self, write: bool) -> (bool, Self) { @@ -101,8 +104,15 @@ impl AccessBitField { let conflicting_exclusive = one_is_exclusive && count != 0; let aliasing_exclusive_access = (kind_requested == 1) && (count != 0); let access_is_forbidden = self.is_forbidden(); - - let allowed = !(conflicting_exclusive || aliasing_exclusive_access || access_is_forbidden); + let write_is_forbidden = (kind_requested == 1) && self.is_write_forbidden(); + // another claim would wrap the count to 0, letting a write alias the existing reads + let count_is_full = count == Self::MAX_COUNT; + + let allowed = !(conflicting_exclusive + || aliasing_exclusive_access + || access_is_forbidden + || write_is_forbidden + || count_is_full); let allowed_u8 = allowed as u8; // Increment count only if allowed @@ -116,7 +126,7 @@ impl AccessBitField { let new_kind_bits = (kind & !kind_mask) | (kind_requested & kind_mask); // Rebuild bitfield - let forbidden_bits = self.0 & Self::FORBIDDEN_MASK; + let forbidden_bits = self.0 & Self::PERMISSION_MASK; let new_count_bits = (new_count << Self::COUNT_SHIFT) & Self::COUNT_MASK; let new_bits = (new_kind_bits & Self::KIND_MASK) | forbidden_bits | new_count_bits; @@ -136,6 +146,19 @@ impl AccessBitField { (self.0 & Self::FORBIDDEN_MASK) != 0 } + #[inline] + /// Returns true if only read accesses may be claimed + pub fn is_write_forbidden(self) -> bool { + (self.0 & Self::WRITE_FORBIDDEN_MASK) != 0 + } + + #[inline] + /// Returns a new state with adjusted write forbidden flag + pub fn with_write_forbidden(self, forbidden: bool) -> Self { + let bit = (forbidden as u8) << 2; + Self((self.0 & !Self::WRITE_FORBIDDEN_MASK) | bit) + } + #[inline] /// Returns the amount of readers/writers pub fn access_count(self) -> u8 { @@ -186,7 +209,7 @@ impl AccessBitField { // Preserve kind only if new != 0, otherwise zero it let kind = self.kind() & nonzero_mask; - let forbidden = self.0 & Self::FORBIDDEN_MASK; + let forbidden = self.0 & Self::PERMISSION_MASK; let new_bits = (kind & Self::KIND_MASK) | forbidden | ((new << Self::COUNT_SHIFT) & Self::COUNT_MASK); @@ -194,9 +217,9 @@ impl AccessBitField { } #[inline] - /// Returns a new state with everything but the forbidden mask cleared + /// Returns a new state with everything but the permissions cleared pub fn clear_accesses(self) -> Self { - Self(self.0 & Self::FORBIDDEN_MASK) + Self(self.0 & Self::PERMISSION_MASK) } } @@ -219,14 +242,27 @@ impl AccessByteSet { } } - /// Generates a new access byte set with the given forbidden list + /// Generates a new access byte set allowing only the given accesses pub fn from_allowed_list(iter: &[usize]) -> Self { - let max_index = iter.iter().max().unwrap_or(&0); + Self::from_allowed_lists(iter, &[]) + } + + /// Generates a new access byte set allowing only the given accesses, the read only ones can't be written. + /// Accesses in both lists can be written. + pub fn from_allowed_lists(read_write: &[usize], read_only: &[usize]) -> Self { + let max_index = read_write.iter().chain(read_only).max().unwrap_or(&0); let mut new = Self::new(); new.new_capacity_forbidden = true; new.ensure_capacity(*max_index); - for i in iter { - new.data[*i] = new.data[*i].with_forbidden(false) + for i in read_only { + new.data[*i] = new.data[*i] + .with_forbidden(false) + .with_write_forbidden(true) + } + for i in read_write { + new.data[*i] = new.data[*i] + .with_forbidden(false) + .with_write_forbidden(false) } new @@ -1060,6 +1096,34 @@ mod test { assert!(!subset_access_map.claim_read_access(WorldAccessRange::Global)); } + #[test] + fn subset_map_read_only_accesses_cannot_be_written() { + let subset_access_map = + AccessMap::new_subset(AccessByteSet::from_allowed_lists(&[1], &[2])); + + assert!(!subset_access_map.claim_write_access(TestAccess(2))); + assert!(subset_access_map.claim_read_access(TestAccess(2))); + assert!(subset_access_map.claim_read_access(TestAccess(2))); + assert!(subset_access_map.claim_write_access(TestAccess(1))); + + // the permission survives releasing all accesses + subset_access_map.release_all_accesses(); + assert!(!subset_access_map.claim_write_access(TestAccess(2))); + } + + #[test] + fn access_counts_do_not_overflow() { + let access_map = AccessMap::default(); + let mut claimed = 0; + while access_map.claim_read_access(TestAccess(0)) { + claimed += 1; + assert!(claimed < 1000, "claims never fail"); + } + assert_eq!(claimed, 31); + // all reads are still counted, so writes stay blocked + assert!(!access_map.claim_write_access(TestAccess(0))); + } + #[test] fn subset_map_retains_subset_in_scope() { let subset_access_map = AccessMap::new_subset(AccessByteSet::from_allowed_list(&[1])); diff --git a/docs/src/ReleaseNotes/0.23-to-0.24.md b/docs/src/ReleaseNotes/0.23-to-0.24.md index d2be51fb19..c97834b765 100644 --- a/docs/src/ReleaseNotes/0.23-to-0.24.md +++ b/docs/src/ReleaseNotes/0.23-to-0.24.md @@ -63,6 +63,8 @@ Other changes: - `ScriptAttachedMessage(a)` / `ScriptDetachedMessage(a)` are one message, `ScriptLifecycleMessage::Attached(a)` / `::Detached(a)`, so attachments and detachments are processed in the order they were written. `GetScriptHandle::get_script_handle` returns an `Option` (`None`: the event doesn't wait for its script to load) and `ScriptLifecycleQueue::get_ready`, formerly known as `LoadedWithHandles` yields `(event, Option<(StrongScriptHandle, Language)>)`. - An attachment whose script is still loading holds back the attachments and detachments after it, and callbacks reach scripts in a deterministic order (attachment order, until scripts are removed), see [Determinism](../ScriptPipeline/pipeline.md#determinism). Lua's `math.random` is seeded with `0` in new contexts. - Script systems are identified by attachment and callback label (adding one again replaces it) and are removed with their script. `ScriptSystemSet` holds a recycled slot instead of the callback label, `ScriptSystemBuilder` no longer implements `IntoSystem` (use `ScriptSystemBuilder::build`), and their errors are sent as `ScriptErrorMessage`s instead of being logged. `WorldAccessGuard::new_non_exclusive` takes an `Option>`, restricting component accesses to the given archetypes (`None` for no restriction). +- Script system parameters are declared to Bevy as they're accessed: `resource` and query `component`s as writes (they were declared as reads, or as accessing nothing for resources, while scripts could write them), `resource_read` / `component_read` as reads. Script systems writing the same resources or components no longer run in parallel. `ScriptQueryBuilder::as_query_state` takes `declare_writes`, `false` declares all components as read (i.e. for `EntityRef` queries). +- `AccessBitField` stores a write forbidden permission (`AccessByteSet::from_allowed_lists(read_write, read_only)`), at most 31 accesses to the same value can be claimed at once (claims beyond fail, the count used to overflow at 64). - `WorldAccessRange::External` holds the value's owner (`AccessOwner`) instead of an id, and the access map keeps it alive until the claim is released. Build it from the value (`(&allocation_id).into()`); `WorldAccessRange` is no longer `Copy`. ### Language implementations diff --git a/docs/src/ScriptSystems/introduction.md b/docs/src/ScriptSystems/introduction.md index 19136e2958..3055fa86cd 100644 --- a/docs/src/ScriptSystems/introduction.md +++ b/docs/src/ScriptSystems/introduction.md @@ -75,6 +75,14 @@ will create a system which calls the specified callback `my_system` with 2 argum - With `components` access to ComponentA and ComponentB - The `ReflectReference` to `ResourceA` +`resource` and `component` give read and write access. Use `resource_read` and `component_read` for read only access, systems reading the same resources and components can run in parallel, while systems writing them cannot: + +```lua +bms.system_builder("my_system", attachment) + :query(world.query():component_read(ComponentA):component(ComponentB)) + :resource_read(Time) +``` + ## Exclusive systems An exclusive system can be created using the `exclusive` function call on the system builder. From ee5f70059ca73c1db1d2656515648db13e69ce8e Mon Sep 17 00:00:00 2001 From: makspll Date: Thu, 22 Oct 2026 14:26:03 +0000 Subject: [PATCH 2/6] perf: keep read only accesses in a bitset checked only by component write claims The write forbidden bit in AccessBitField made field get/set 4-8% slower, the bitset leaves read claims untouched and keeps the max claim count at 63. --- .../src/access_map.rs | 64 +++++++++---------- docs/src/ReleaseNotes/0.23-to-0.24.md | 2 +- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/crates/bevy_mod_scripting_world/src/access_map.rs b/crates/bevy_mod_scripting_world/src/access_map.rs index 4c99685abc..1149ce5c3d 100644 --- a/crates/bevy_mod_scripting_world/src/access_map.rs +++ b/crates/bevy_mod_scripting_world/src/access_map.rs @@ -87,12 +87,9 @@ pub struct AccessBitField(u8); impl AccessBitField { const KIND_MASK: u8 = 0b0000_0001; const FORBIDDEN_MASK: u8 = 0b0000_0010; - const WRITE_FORBIDDEN_MASK: u8 = 0b0000_0100; - /// The permissions, which are kept when accesses are claimed and released - const PERMISSION_MASK: u8 = Self::FORBIDDEN_MASK | Self::WRITE_FORBIDDEN_MASK; - const COUNT_MASK: u8 = 0b1111_1000; - const COUNT_SHIFT: u8 = 3; - const MAX_COUNT: u8 = 0b0001_1111; // 31 + const COUNT_MASK: u8 = 0b1111_1100; + const COUNT_SHIFT: u8 = 2; + const MAX_COUNT: u8 = 0b0011_1111; // 63 /// Claims the given access and returns an adjusted state pub fn claim_if_free(self, write: bool) -> (bool, Self) { @@ -104,14 +101,12 @@ impl AccessBitField { let conflicting_exclusive = one_is_exclusive && count != 0; let aliasing_exclusive_access = (kind_requested == 1) && (count != 0); let access_is_forbidden = self.is_forbidden(); - let write_is_forbidden = (kind_requested == 1) && self.is_write_forbidden(); // another claim would wrap the count to 0, letting a write alias the existing reads let count_is_full = count == Self::MAX_COUNT; let allowed = !(conflicting_exclusive || aliasing_exclusive_access || access_is_forbidden - || write_is_forbidden || count_is_full); let allowed_u8 = allowed as u8; @@ -126,7 +121,7 @@ impl AccessBitField { let new_kind_bits = (kind & !kind_mask) | (kind_requested & kind_mask); // Rebuild bitfield - let forbidden_bits = self.0 & Self::PERMISSION_MASK; + let forbidden_bits = self.0 & Self::FORBIDDEN_MASK; let new_count_bits = (new_count << Self::COUNT_SHIFT) & Self::COUNT_MASK; let new_bits = (new_kind_bits & Self::KIND_MASK) | forbidden_bits | new_count_bits; @@ -146,19 +141,6 @@ impl AccessBitField { (self.0 & Self::FORBIDDEN_MASK) != 0 } - #[inline] - /// Returns true if only read accesses may be claimed - pub fn is_write_forbidden(self) -> bool { - (self.0 & Self::WRITE_FORBIDDEN_MASK) != 0 - } - - #[inline] - /// Returns a new state with adjusted write forbidden flag - pub fn with_write_forbidden(self, forbidden: bool) -> Self { - let bit = (forbidden as u8) << 2; - Self((self.0 & !Self::WRITE_FORBIDDEN_MASK) | bit) - } - #[inline] /// Returns the amount of readers/writers pub fn access_count(self) -> u8 { @@ -209,7 +191,7 @@ impl AccessBitField { // Preserve kind only if new != 0, otherwise zero it let kind = self.kind() & nonzero_mask; - let forbidden = self.0 & Self::PERMISSION_MASK; + let forbidden = self.0 & Self::FORBIDDEN_MASK; let new_bits = (kind & Self::KIND_MASK) | forbidden | ((new << Self::COUNT_SHIFT) & Self::COUNT_MASK); @@ -217,9 +199,9 @@ impl AccessBitField { } #[inline] - /// Returns a new state with everything but the permissions cleared + /// Returns a new state with everything but the forbidden mask cleared pub fn clear_accesses(self) -> Self { - Self(self.0 & Self::PERMISSION_MASK) + Self(self.0 & Self::FORBIDDEN_MASK) } } @@ -229,6 +211,9 @@ impl AccessBitField { pub struct AccessByteSet { new_capacity_forbidden: bool, data: Vec, + /// Bitset of accesses which can only be read, empty unless the set is a subset with read only accesses. + /// Kept outside of the [`AccessBitField`]s so that only write claims pay for checking it. + read_only: Vec, } #[profiling::all_functions] @@ -239,6 +224,7 @@ impl AccessByteSet { Self { data: Vec::new(), new_capacity_forbidden: false, + read_only: Vec::new(), } } @@ -254,20 +240,29 @@ impl AccessByteSet { let mut new = Self::new(); new.new_capacity_forbidden = true; new.ensure_capacity(*max_index); - for i in read_only { - new.data[*i] = new.data[*i] - .with_forbidden(false) - .with_write_forbidden(true) + for i in read_only.iter().filter(|i| !read_write.contains(i)) { + new.data[*i] = new.data[*i].with_forbidden(false); + let (word, bit) = (i / 64, i % 64); + if word >= new.read_only.len() { + new.read_only.resize(word + 1, 0); + } + new.read_only[word] |= 1 << bit; } for i in read_write { - new.data[*i] = new.data[*i] - .with_forbidden(false) - .with_write_forbidden(false) + new.data[*i] = new.data[*i].with_forbidden(false) } new } + #[inline] + /// Returns true if the access can only be read + pub fn is_read_only(&self, index: usize) -> bool { + self.read_only + .get(index / 64) + .is_some_and(|word| word & (1 << (index % 64)) != 0) + } + fn ensure_capacity(&mut self, index: usize) { if index + 1 > self.data.len() { self.data.resize_with(index + 1, || { @@ -444,6 +439,9 @@ impl AccessMapInner { pub fn claim_component_if_free(&mut self, id: ComponentId, write: bool) -> bool { let idx = id.index(); + if write && self.component_access.is_read_only(idx) { + return false; + } let elem = self.component_access.entry_mut(idx); let (free, new) = elem.claim_if_free(write); *elem = new; @@ -1119,7 +1117,7 @@ mod test { claimed += 1; assert!(claimed < 1000, "claims never fail"); } - assert_eq!(claimed, 31); + assert_eq!(claimed, 63); // all reads are still counted, so writes stay blocked assert!(!access_map.claim_write_access(TestAccess(0))); } diff --git a/docs/src/ReleaseNotes/0.23-to-0.24.md b/docs/src/ReleaseNotes/0.23-to-0.24.md index c97834b765..4d34b07cd8 100644 --- a/docs/src/ReleaseNotes/0.23-to-0.24.md +++ b/docs/src/ReleaseNotes/0.23-to-0.24.md @@ -64,7 +64,7 @@ Other changes: - An attachment whose script is still loading holds back the attachments and detachments after it, and callbacks reach scripts in a deterministic order (attachment order, until scripts are removed), see [Determinism](../ScriptPipeline/pipeline.md#determinism). Lua's `math.random` is seeded with `0` in new contexts. - Script systems are identified by attachment and callback label (adding one again replaces it) and are removed with their script. `ScriptSystemSet` holds a recycled slot instead of the callback label, `ScriptSystemBuilder` no longer implements `IntoSystem` (use `ScriptSystemBuilder::build`), and their errors are sent as `ScriptErrorMessage`s instead of being logged. `WorldAccessGuard::new_non_exclusive` takes an `Option>`, restricting component accesses to the given archetypes (`None` for no restriction). - Script system parameters are declared to Bevy as they're accessed: `resource` and query `component`s as writes (they were declared as reads, or as accessing nothing for resources, while scripts could write them), `resource_read` / `component_read` as reads. Script systems writing the same resources or components no longer run in parallel. `ScriptQueryBuilder::as_query_state` takes `declare_writes`, `false` declares all components as read (i.e. for `EntityRef` queries). -- `AccessBitField` stores a write forbidden permission (`AccessByteSet::from_allowed_lists(read_write, read_only)`), at most 31 accesses to the same value can be claimed at once (claims beyond fail, the count used to overflow at 64). +- `AccessByteSet::from_allowed_lists(read_write, read_only)` creates subsets with read only accesses. At most 63 accesses to the same value can be claimed at once, claims beyond fail (the count used to overflow). - `WorldAccessRange::External` holds the value's owner (`AccessOwner`) instead of an id, and the access map keeps it alive until the claim is released. Build it from the value (`(&allocation_id).into()`); `WorldAccessRange` is no longer `Copy`. ### Language implementations From e0b3cd136cd7109a0599435b6fd04ef407ac71fc Mon Sep 17 00:00:00 2001 From: makspll Date: Fri, 23 Oct 2026 14:01:58 +0000 Subject: [PATCH 3/6] refactor: share a bitset between the access map and archetype filter, read only and read write accesses are disjoint --- .../src/access_map.rs | 33 +++++++-------- .../src/archetype_filter.rs | 21 ++++------ .../bevy_mod_scripting_world/src/bit_set.rs | 40 +++++++++++++++++++ crates/bevy_mod_scripting_world/src/lib.rs | 1 + 4 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 crates/bevy_mod_scripting_world/src/bit_set.rs diff --git a/crates/bevy_mod_scripting_world/src/access_map.rs b/crates/bevy_mod_scripting_world/src/access_map.rs index 1149ce5c3d..13bd8cbe83 100644 --- a/crates/bevy_mod_scripting_world/src/access_map.rs +++ b/crates/bevy_mod_scripting_world/src/access_map.rs @@ -7,6 +7,8 @@ use bevy_ecs::component::ComponentId; use bevy_platform::collections::HashMap; use parking_lot::Mutex; +use crate::bit_set::BitSet; + /// Describes access ranges in and outside a bevy world #[derive(Clone)] pub enum WorldAccessRange { @@ -211,9 +213,9 @@ impl AccessBitField { pub struct AccessByteSet { new_capacity_forbidden: bool, data: Vec, - /// Bitset of accesses which can only be read, empty unless the set is a subset with read only accesses. + /// Accesses which can only be read, empty unless the set is a subset with read only accesses. /// Kept outside of the [`AccessBitField`]s so that only write claims pay for checking it. - read_only: Vec, + read_only: BitSet, } #[profiling::all_functions] @@ -224,7 +226,7 @@ impl AccessByteSet { Self { data: Vec::new(), new_capacity_forbidden: false, - read_only: Vec::new(), + read_only: BitSet::default(), } } @@ -234,22 +236,23 @@ impl AccessByteSet { } /// Generates a new access byte set allowing only the given accesses, the read only ones can't be written. - /// Accesses in both lists can be written. + /// + /// The lists must be disjoint, callers split their accesses by whether they are written. + /// Every other access is forbidden. pub fn from_allowed_lists(read_write: &[usize], read_only: &[usize]) -> Self { + debug_assert!( + read_only.iter().all(|i| !read_write.contains(i)), + "read only and read write accesses overlap" + ); let max_index = read_write.iter().chain(read_only).max().unwrap_or(&0); let mut new = Self::new(); new.new_capacity_forbidden = true; new.ensure_capacity(*max_index); - for i in read_only.iter().filter(|i| !read_write.contains(i)) { - new.data[*i] = new.data[*i].with_forbidden(false); - let (word, bit) = (i / 64, i % 64); - if word >= new.read_only.len() { - new.read_only.resize(word + 1, 0); - } - new.read_only[word] |= 1 << bit; + for &i in read_write.iter().chain(read_only) { + new.data[i] = new.data[i].with_forbidden(false); } - for i in read_write { - new.data[*i] = new.data[*i].with_forbidden(false) + for &i in read_only { + new.read_only.insert(i); } new @@ -258,9 +261,7 @@ impl AccessByteSet { #[inline] /// Returns true if the access can only be read pub fn is_read_only(&self, index: usize) -> bool { - self.read_only - .get(index / 64) - .is_some_and(|word| word & (1 << (index % 64)) != 0) + self.read_only.contains(index) } fn ensure_capacity(&mut self, index: usize) { diff --git a/crates/bevy_mod_scripting_world/src/archetype_filter.rs b/crates/bevy_mod_scripting_world/src/archetype_filter.rs index a9ff970beb..7428e7d4ad 100644 --- a/crates/bevy_mod_scripting_world/src/archetype_filter.rs +++ b/crates/bevy_mod_scripting_world/src/archetype_filter.rs @@ -29,18 +29,20 @@ use bevy_ecs::{archetype::ArchetypeId, component::ComponentId}; +use crate::bit_set::BitSet; + /// Restricts access to components to entities in a set of archetypes, per component. /// /// Components without a restriction are not affected, i.e. resources and components accessed by exclusive guards. #[derive(Clone, Debug, Default)] pub struct ArchetypeFilter { - /// Restricted components, each with a bitset indexed by [`ArchetypeId::index`]. + /// Restricted components, each with the set of allowed [`ArchetypeId::index`]es. /// Systems restrict a handful of components, so a linear scan beats indexing by component id. - restricted: Vec<(ComponentId, Vec)>, + restricted: Vec<(ComponentId, BitSet)>, } impl ArchetypeFilter { - fn bits_mut(&mut self, component_id: ComponentId) -> &mut Vec { + fn bits_mut(&mut self, component_id: ComponentId) -> &mut BitSet { let index = match self .restricted .iter() @@ -48,7 +50,7 @@ impl ArchetypeFilter { { Some(index) => index, None => { - self.restricted.push((component_id, Vec::new())); + self.restricted.push((component_id, BitSet::default())); self.restricted.len() - 1 } }; @@ -59,12 +61,7 @@ impl ArchetypeFilter { /// /// Once called for a component, the component is restricted to the archetypes allowed this way. pub fn allow(&mut self, component_id: ComponentId, archetype_id: ArchetypeId) { - let bits = self.bits_mut(component_id); - let (word, bit) = (archetype_id.index() / 64, archetype_id.index() % 64); - if word >= bits.len() { - bits.resize(word + 1, 0); - } - bits[word] |= 1 << bit; + self.bits_mut(component_id).insert(archetype_id.index()); } /// Restricts the given component to no archetypes, until [`Self::allow`] is called for it. @@ -76,9 +73,7 @@ impl ArchetypeFilter { #[inline] pub fn is_allowed(&self, component_id: ComponentId, archetype_id: ArchetypeId) -> bool { match self.restricted.iter().find(|(id, _)| *id == component_id) { - Some((_, bits)) => bits - .get(archetype_id.index() / 64) - .is_some_and(|word| word & (1 << (archetype_id.index() % 64)) != 0), + Some((_, archetypes)) => archetypes.contains(archetype_id.index()), None => true, } } diff --git a/crates/bevy_mod_scripting_world/src/bit_set.rs b/crates/bevy_mod_scripting_world/src/bit_set.rs new file mode 100644 index 0000000000..395c9f8442 --- /dev/null +++ b/crates/bevy_mod_scripting_world/src/bit_set.rs @@ -0,0 +1,40 @@ +//! A growable bitset, for per index flags kept off the hot claim path. + +/// A growable bitset indexed by `usize`, i.e. by [`bevy_ecs::component::ComponentId::index`]. +/// +/// Indices past the end are not contained. +#[derive(Clone, Debug, Default)] +pub(crate) struct BitSet(Vec); + +impl BitSet { + /// Adds the index, growing the set if needed + pub(crate) fn insert(&mut self, index: usize) { + let (word, bit) = (index / 64, index % 64); + if word >= self.0.len() { + self.0.resize(word + 1, 0); + } + self.0[word] |= 1 << bit; + } + + /// Returns true if the index was inserted + #[inline] + pub(crate) fn contains(&self, index: usize) -> bool { + self.0 + .get(index / 64) + .is_some_and(|word| word & (1 << (index % 64)) != 0) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn bit_set_contains_only_inserted_indices() { + let mut set = BitSet::default(); + set.insert(1); + set.insert(130); + assert!(set.contains(1) && set.contains(130)); + assert!(!set.contains(0) && !set.contains(64) && !set.contains(1000)); + } +} diff --git a/crates/bevy_mod_scripting_world/src/lib.rs b/crates/bevy_mod_scripting_world/src/lib.rs index db6948ef59..c33f155c22 100644 --- a/crates/bevy_mod_scripting_world/src/lib.rs +++ b/crates/bevy_mod_scripting_world/src/lib.rs @@ -2,6 +2,7 @@ mod access_map; mod archetype_filter; +mod bit_set; mod world; pub use access_map::*; From 64465701d29847efad06b29d52e0c31b8a1d845d Mon Sep 17 00:00:00 2001 From: makspll Date: Fri, 23 Oct 2026 14:03:45 +0000 Subject: [PATCH 4/6] refactor: query states declare their access by a QueryAccess instead of a bool --- .../bevy_mod_scripting_bindings/src/query.rs | 25 ++++++++++++++----- .../src/world_extensions.rs | 3 ++- .../src/script_system.rs | 4 +-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/bevy_mod_scripting_bindings/src/query.rs b/crates/bevy_mod_scripting_bindings/src/query.rs index c31d3c1df0..d337c25d9b 100644 --- a/crates/bevy_mod_scripting_bindings/src/query.rs +++ b/crates/bevy_mod_scripting_bindings/src/query.rs @@ -407,6 +407,22 @@ impl std::fmt::Display for ScriptTypeRegistration { } } +/// How [`ScriptQueryBuilder::as_query_state`] declares the accesses of the query. +/// +/// Read and written components match the same entities, the difference is only in the access the query reports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QueryAccess { + /// Every component is declared as read, for finding the matching entities, i.e. with + /// [`bevy_ecs::query::QueryState::iter`] on a `&World`, while the actual component accesses are claimed through + /// the world guard. + /// + /// The query state must not be used to write components, or have its access reported to the scheduler. + MatchOnly, + /// The written components are declared as written, for queries whose access is reported to the scheduler, + /// i.e. the queries of script systems. + Declared, +} + #[derive(Clone, Default, Reflect)] #[reflect(opaque)] /// The query builder is used to build ECS queries which retrieve spefific components filtered by specific conditions. @@ -502,20 +518,17 @@ impl ScriptQueryBuilder { self } - /// Builds the query into a query state as used in systems. - /// - /// With `declare_writes`, the written components are declared as written in the query's access, as needed when the - /// query's access is reported to the scheduler. Otherwise all components are declared as read. + /// Builds the query into a query state, declaring its component accesses as given by `access`. pub fn as_query_state( &self, world: &mut World, - declare_writes: bool, + access: QueryAccess, ) -> QueryState { let mut dynamic_query = QueryBuilder::::new(world); // we don't actually want to fetch the data for components now, only figure out // which entities match the query, and declare the access for (index, c) in self.components.iter().enumerate() { - if declare_writes && self.is_written(index) { + if access == QueryAccess::Declared && self.is_written(index) { dynamic_query.mut_id(c.component_id()); } else { dynamic_query.ref_id(c.component_id()); diff --git a/crates/bevy_mod_scripting_bindings/src/world_extensions.rs b/crates/bevy_mod_scripting_bindings/src/world_extensions.rs index 9d62ed71fb..7b85447110 100644 --- a/crates/bevy_mod_scripting_bindings/src/world_extensions.rs +++ b/crates/bevy_mod_scripting_bindings/src/world_extensions.rs @@ -509,7 +509,8 @@ impl<'w> WorldExtensions for WorldAccessGuard<'w> { query: crate::ScriptQueryBuilder, ) -> Result, InteropError> { self.with_world_mut(|world| { - let mut built_query = query.as_query_state::(world, false); + let mut built_query = + query.as_query_state::(world, crate::QueryAccess::MatchOnly); let query_result = built_query.iter(world); Ok(query_result diff --git a/crates/bevy_mod_scripting_core/src/script_system.rs b/crates/bevy_mod_scripting_core/src/script_system.rs index 3be371349f..73e5518588 100644 --- a/crates/bevy_mod_scripting_core/src/script_system.rs +++ b/crates/bevy_mod_scripting_core/src/script_system.rs @@ -38,7 +38,7 @@ use bevy_log::{debug, warn, warn_once}; use bevy_mod_scripting_bindings::{ AppScheduleRegistry, AppScriptCallbackRunners, AppScriptComponentRegistry, AppScriptFunctionRegistry, AppScriptTypeCatalogue, CurrentScriptAttachment, InteropError, - IntoScript, ReflectReference, ScriptQueryBuilder, ScriptQueryResult, + IntoScript, QueryAccess, ReflectReference, ScriptQueryBuilder, ScriptQueryResult, ScriptResourceRegistration, V, WorldExtensions, }; use bevy_mod_scripting_display::DisplayProxy; @@ -901,7 +901,7 @@ impl System for DynamicScriptSystem

{ .iter() .map(|c| (c.component_id, c.type_registration().type_id())) .collect(); - let query = query.as_query_state::(world, true); + let query = query.as_query_state::(world, QueryAccess::Declared); // The access carries the query filters, so bevy runs this system in parallel with systems accessing // the same components on other archetypes (i.e. `With` vs `Without`). From 671ec4640ce8b4411640a4549e6f2b79835677f0 Mon Sep 17 00:00:00 2001 From: makspll Date: Fri, 23 Oct 2026 14:48:16 +0000 Subject: [PATCH 5/6] refactor: use bevy's FixedBitSet instead of a hand written bitset --- .../src/access_map.rs | 8 ++-- .../src/archetype_filter.rs | 11 ++--- .../bevy_mod_scripting_world/src/bit_set.rs | 40 ------------------- crates/bevy_mod_scripting_world/src/lib.rs | 1 - 4 files changed, 10 insertions(+), 50 deletions(-) delete mode 100644 crates/bevy_mod_scripting_world/src/bit_set.rs diff --git a/crates/bevy_mod_scripting_world/src/access_map.rs b/crates/bevy_mod_scripting_world/src/access_map.rs index 13bd8cbe83..a3b5cfc6aa 100644 --- a/crates/bevy_mod_scripting_world/src/access_map.rs +++ b/crates/bevy_mod_scripting_world/src/access_map.rs @@ -7,7 +7,7 @@ use bevy_ecs::component::ComponentId; use bevy_platform::collections::HashMap; use parking_lot::Mutex; -use crate::bit_set::BitSet; +use bevy_ecs::schedule::FixedBitSet; /// Describes access ranges in and outside a bevy world #[derive(Clone)] @@ -215,7 +215,7 @@ pub struct AccessByteSet { data: Vec, /// Accesses which can only be read, empty unless the set is a subset with read only accesses. /// Kept outside of the [`AccessBitField`]s so that only write claims pay for checking it. - read_only: BitSet, + read_only: FixedBitSet, } #[profiling::all_functions] @@ -226,7 +226,7 @@ impl AccessByteSet { Self { data: Vec::new(), new_capacity_forbidden: false, - read_only: BitSet::default(), + read_only: FixedBitSet::new(), } } @@ -252,7 +252,7 @@ impl AccessByteSet { new.data[i] = new.data[i].with_forbidden(false); } for &i in read_only { - new.read_only.insert(i); + new.read_only.grow_and_insert(i); } new diff --git a/crates/bevy_mod_scripting_world/src/archetype_filter.rs b/crates/bevy_mod_scripting_world/src/archetype_filter.rs index 7428e7d4ad..c62b539ef8 100644 --- a/crates/bevy_mod_scripting_world/src/archetype_filter.rs +++ b/crates/bevy_mod_scripting_world/src/archetype_filter.rs @@ -29,7 +29,7 @@ use bevy_ecs::{archetype::ArchetypeId, component::ComponentId}; -use crate::bit_set::BitSet; +use bevy_ecs::schedule::FixedBitSet; /// Restricts access to components to entities in a set of archetypes, per component. /// @@ -38,11 +38,11 @@ use crate::bit_set::BitSet; pub struct ArchetypeFilter { /// Restricted components, each with the set of allowed [`ArchetypeId::index`]es. /// Systems restrict a handful of components, so a linear scan beats indexing by component id. - restricted: Vec<(ComponentId, BitSet)>, + restricted: Vec<(ComponentId, FixedBitSet)>, } impl ArchetypeFilter { - fn bits_mut(&mut self, component_id: ComponentId) -> &mut BitSet { + fn bits_mut(&mut self, component_id: ComponentId) -> &mut FixedBitSet { let index = match self .restricted .iter() @@ -50,7 +50,7 @@ impl ArchetypeFilter { { Some(index) => index, None => { - self.restricted.push((component_id, BitSet::default())); + self.restricted.push((component_id, FixedBitSet::new())); self.restricted.len() - 1 } }; @@ -61,7 +61,8 @@ impl ArchetypeFilter { /// /// Once called for a component, the component is restricted to the archetypes allowed this way. pub fn allow(&mut self, component_id: ComponentId, archetype_id: ArchetypeId) { - self.bits_mut(component_id).insert(archetype_id.index()); + self.bits_mut(component_id) + .grow_and_insert(archetype_id.index()); } /// Restricts the given component to no archetypes, until [`Self::allow`] is called for it. diff --git a/crates/bevy_mod_scripting_world/src/bit_set.rs b/crates/bevy_mod_scripting_world/src/bit_set.rs deleted file mode 100644 index 395c9f8442..0000000000 --- a/crates/bevy_mod_scripting_world/src/bit_set.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! A growable bitset, for per index flags kept off the hot claim path. - -/// A growable bitset indexed by `usize`, i.e. by [`bevy_ecs::component::ComponentId::index`]. -/// -/// Indices past the end are not contained. -#[derive(Clone, Debug, Default)] -pub(crate) struct BitSet(Vec); - -impl BitSet { - /// Adds the index, growing the set if needed - pub(crate) fn insert(&mut self, index: usize) { - let (word, bit) = (index / 64, index % 64); - if word >= self.0.len() { - self.0.resize(word + 1, 0); - } - self.0[word] |= 1 << bit; - } - - /// Returns true if the index was inserted - #[inline] - pub(crate) fn contains(&self, index: usize) -> bool { - self.0 - .get(index / 64) - .is_some_and(|word| word & (1 << (index % 64)) != 0) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn bit_set_contains_only_inserted_indices() { - let mut set = BitSet::default(); - set.insert(1); - set.insert(130); - assert!(set.contains(1) && set.contains(130)); - assert!(!set.contains(0) && !set.contains(64) && !set.contains(1000)); - } -} diff --git a/crates/bevy_mod_scripting_world/src/lib.rs b/crates/bevy_mod_scripting_world/src/lib.rs index c33f155c22..db6948ef59 100644 --- a/crates/bevy_mod_scripting_world/src/lib.rs +++ b/crates/bevy_mod_scripting_world/src/lib.rs @@ -2,7 +2,6 @@ mod access_map; mod archetype_filter; -mod bit_set; mod world; pub use access_map::*; From 6daceabe4bfcb9012cd35eac16ef1356619c0622 Mon Sep 17 00:00:00 2001 From: makspll Date: Fri, 23 Oct 2026 16:38:33 +0000 Subject: [PATCH 6/6] docs: as_query_state takes a QueryAccess --- docs/src/ReleaseNotes/0.23-to-0.24.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ReleaseNotes/0.23-to-0.24.md b/docs/src/ReleaseNotes/0.23-to-0.24.md index 4d34b07cd8..a64dfd5732 100644 --- a/docs/src/ReleaseNotes/0.23-to-0.24.md +++ b/docs/src/ReleaseNotes/0.23-to-0.24.md @@ -63,7 +63,7 @@ Other changes: - `ScriptAttachedMessage(a)` / `ScriptDetachedMessage(a)` are one message, `ScriptLifecycleMessage::Attached(a)` / `::Detached(a)`, so attachments and detachments are processed in the order they were written. `GetScriptHandle::get_script_handle` returns an `Option` (`None`: the event doesn't wait for its script to load) and `ScriptLifecycleQueue::get_ready`, formerly known as `LoadedWithHandles` yields `(event, Option<(StrongScriptHandle, Language)>)`. - An attachment whose script is still loading holds back the attachments and detachments after it, and callbacks reach scripts in a deterministic order (attachment order, until scripts are removed), see [Determinism](../ScriptPipeline/pipeline.md#determinism). Lua's `math.random` is seeded with `0` in new contexts. - Script systems are identified by attachment and callback label (adding one again replaces it) and are removed with their script. `ScriptSystemSet` holds a recycled slot instead of the callback label, `ScriptSystemBuilder` no longer implements `IntoSystem` (use `ScriptSystemBuilder::build`), and their errors are sent as `ScriptErrorMessage`s instead of being logged. `WorldAccessGuard::new_non_exclusive` takes an `Option>`, restricting component accesses to the given archetypes (`None` for no restriction). -- Script system parameters are declared to Bevy as they're accessed: `resource` and query `component`s as writes (they were declared as reads, or as accessing nothing for resources, while scripts could write them), `resource_read` / `component_read` as reads. Script systems writing the same resources or components no longer run in parallel. `ScriptQueryBuilder::as_query_state` takes `declare_writes`, `false` declares all components as read (i.e. for `EntityRef` queries). +- Script system parameters are declared to Bevy as they're accessed: `resource` and query `component`s as writes (they were declared as reads, or as accessing nothing for resources, while scripts could write them), `resource_read` / `component_read` as reads. Script systems writing the same resources or components no longer run in parallel. `ScriptQueryBuilder::as_query_state` takes a `QueryAccess`: `Declared` declares the written components as written (for queries reported to the scheduler), `MatchOnly` declares all components as read (i.e. for `EntityRef` queries only finding entities). - `AccessByteSet::from_allowed_lists(read_write, read_only)` creates subsets with read only accesses. At most 63 accesses to the same value can be claimed at once, claims beyond fail (the count used to overflow). - `WorldAccessRange::External` holds the value's owner (`AccessOwner`) instead of an id, and the access map keeps it alive until the claim is released. Build it from the value (`(&allocation_id).into()`); `WorldAccessRange` is no longer `Copy`.