khora_data/ecs/page.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{
16 any::{Any, TypeId},
17 collections::HashMap,
18 fmt,
19};
20
21use bincode::{Decode, Encode};
22use khora_core::ecs::entity::EntityId;
23
24/// Upper bound on the byte payload accepted by [`AnyVec::set_from_bytes`] for a
25/// single component column.
26///
27/// Deserialization feeds this length straight into a `Vec` reservation, so an
28/// untrusted scene/pack file could otherwise request an arbitrarily large
29/// allocation and abort the process with an OOM. Real component columns are
30/// small (transforms, handles, a few scalars per entity); even a million
31/// entities of a 256-byte component is 256 MiB, so this ceiling comfortably
32/// covers legitimate scenes while rejecting hostile inputs before any
33/// allocation happens.
34pub const MAX_COLUMN_PAYLOAD_BYTES: usize = 256 * 1024 * 1024;
35
36/// Error returned when raw column bytes fail validation in
37/// [`AnyVec::set_from_bytes`].
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum SetFromBytesError {
40 /// The byte length is not an exact multiple of the column's element size,
41 /// so it cannot describe a whole number of elements.
42 MisalignedLength {
43 /// Length of the supplied byte buffer.
44 len: usize,
45 /// Size of a single element in the column.
46 elem_size: usize,
47 },
48 /// The byte length exceeds [`MAX_COLUMN_PAYLOAD_BYTES`].
49 PayloadTooLarge {
50 /// Length of the supplied byte buffer.
51 len: usize,
52 /// The maximum accepted payload size.
53 max: usize,
54 },
55}
56
57impl fmt::Display for SetFromBytesError {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 SetFromBytesError::MisalignedLength { len, elem_size } => write!(
61 f,
62 "column byte length {len} is not a multiple of element size {elem_size}"
63 ),
64 SetFromBytesError::PayloadTooLarge { len, max } => write!(
65 f,
66 "column byte length {len} exceeds the maximum payload size {max}"
67 ),
68 }
69 }
70}
71
72impl std::error::Error for SetFromBytesError {}
73
74/// An internal helper trait to perform vector operations on a type-erased `Box<dyn Any>`.
75///
76/// This allows us to call methods like `swap_remove` on component columns without
77/// needing to know their concrete `Vec<T>` type at compile time.
78pub trait AnyVec: Any + Send + Sync {
79 /// Casts the trait object to `&dyn Any`.
80 fn as_any(&self) -> &dyn Any;
81
82 /// Casts the trait object to `&mut dyn Any`.
83 fn as_any_mut(&mut self) -> &mut dyn Any;
84
85 /// Performs a `swap_remove` on the underlying column, removing the element at `index`.
86 fn swap_remove_any(&mut self, index: usize);
87
88 /// Serialises the column to an owned byte buffer.
89 ///
90 /// Returns an *owned* `Vec<u8>` (not a borrowed slice) so that columns whose
91 /// data is not a single contiguous buffer — e.g. a field-split SoA column —
92 /// can materialise their bytes. The format is the column's own concern; the
93 /// only contract is that [`set_from_bytes`](AnyVec::set_from_bytes) on the
94 /// same column type reverses it.
95 fn to_bytes(&self) -> Vec<u8>;
96
97 /// Replaces the column contents from bytes previously produced by
98 /// [`to_bytes`](AnyVec::to_bytes) **on the same column type**.
99 ///
100 /// The length is validated against the column's element size and against
101 /// [`MAX_COLUMN_PAYLOAD_BYTES`] before any allocation, so untrusted input
102 /// cannot force an out-of-memory abort; a malformed length returns
103 /// [`SetFromBytesError`] and leaves the column unchanged.
104 ///
105 /// # Safety
106 /// The caller must guarantee the bytes match this column's element *type*,
107 /// element size, and alignment. Length validation alone cannot detect a
108 /// type mismatch, so feeding bytes produced by a different column type is
109 /// still undefined behaviour.
110 unsafe fn set_from_bytes(&mut self, bytes: &[u8]) -> Result<(), SetFromBytesError>;
111}
112
113// We implement this trait for any `Vec<T>` where T is `'static`.
114impl<T: 'static + Send + Sync> AnyVec for Vec<T> {
115 fn as_any(&self) -> &dyn Any {
116 self
117 }
118
119 fn as_any_mut(&mut self) -> &mut dyn Any {
120 self
121 }
122
123 fn swap_remove_any(&mut self, index: usize) {
124 self.swap_remove(index);
125 }
126
127 fn to_bytes(&self) -> Vec<u8> {
128 // SAFETY: reads `len * size_of::<T>()` initialised bytes from the Vec's
129 // buffer and copies them into an owned `Vec<u8>`.
130 unsafe {
131 std::slice::from_raw_parts(
132 self.as_ptr() as *const u8,
133 self.len() * std::mem::size_of::<T>(),
134 )
135 .to_vec()
136 }
137 }
138
139 unsafe fn set_from_bytes(&mut self, bytes: &[u8]) -> Result<(), SetFromBytesError> {
140 let elem_size = std::mem::size_of::<T>();
141 if elem_size == 0 {
142 return Ok(()); // Correctly handle Zero-Sized Types.
143 }
144
145 // Validate the untrusted length *before* reserving, so a hostile scene
146 // file cannot trigger a huge allocation or a partial copy.
147 if !bytes.len().is_multiple_of(elem_size) {
148 return Err(SetFromBytesError::MisalignedLength {
149 len: bytes.len(),
150 elem_size,
151 });
152 }
153 if bytes.len() > MAX_COLUMN_PAYLOAD_BYTES {
154 return Err(SetFromBytesError::PayloadTooLarge {
155 len: bytes.len(),
156 max: MAX_COLUMN_PAYLOAD_BYTES,
157 });
158 }
159
160 // Calculate the new length and resize the Vec accordingly.
161 let new_len = bytes.len() / elem_size;
162 self.clear();
163 self.reserve(new_len);
164
165 // SAFETY: `reserve(new_len)` guaranteed capacity for `new_len` elements,
166 // i.e. `new_len * elem_size == bytes.len()` initialised bytes (the length
167 // was validated as an exact multiple above). The source and destination
168 // are distinct, non-overlapping allocations, so `copy_nonoverlapping` of
169 // exactly `bytes.len()` bytes is in-bounds; `set_len(new_len)` then marks
170 // those bytes initialised. Element *type* correctness is the documented
171 // obligation of the caller.
172 let ptr = self.as_mut_ptr() as *mut u8;
173 std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, bytes.len());
174 self.set_len(new_len);
175 Ok(())
176 }
177}
178
179/// A logical address pointing to an entity's component data within a specific `ComponentPage`.
180///
181/// This struct is the core of the relational aspect of our ECS. It decouples an entity's
182/// identity from the physical storage of its data by acting as a coordinate.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Encode, Decode)]
184pub struct PageIndex {
185 /// The unique identifier of the `ComponentPage` that stores the component data.
186 pub page_id: u32,
187 /// The index of the row within the page where this entity's components are stored.
188 pub row_index: u32,
189}
190
191/// A serializable representation of a single `ComponentPage`.
192#[derive(Encode, Decode)]
193pub(crate) struct SerializedPage {
194 /// The unique identifiers of this page.
195 pub(crate) type_names: Vec<String>,
196 /// The list of entities whose component data is stored in this page.
197 pub(crate) entities: Vec<EntityId>,
198 /// The actual serialized component data columns. Each column is a byte vector
199 /// representing the serialized `Vec<T>` for a specific component
200 pub(crate) columns: HashMap<String, Vec<u8>>,
201}
202
203/// A page of memory that stores the component data for multiple entities
204/// in a Structure of Arrays (SoA) layout.
205///
206/// A `ComponentPage` is specialized for a single semantic domain (e.g., physics).
207/// It contains multiple columns, where each column is a `Vec<T>` for a specific
208/// component type `T`. This SoA layout is the key to our high iteration performance,
209/// as it guarantees contiguous data access for native queries.
210pub struct ComponentPage {
211 /// A map from a component's `TypeId` to its actual storage column.
212 /// The `Box<dyn AnyVec>` is a type-erased `Vec<T>` that knows how to
213 /// perform basic vector operations like `swap_remove`.
214 pub(crate) columns: HashMap<TypeId, Box<dyn AnyVec>>,
215
216 /// A list of the `EntityId`s that own the data in each row of this page.
217 /// The entity at `entities[i]` corresponds to the components at `columns[...][i]`.
218 /// This is crucial for reverse lookups, especially during entity despawning.
219 pub(crate) entities: Vec<EntityId>,
220
221 /// The sorted list of `TypeId`s for the components stored in this page.
222 /// This acts as the page's "signature" for matching with bundles. It is
223 /// kept sorted to ensure that the signature is canonical.
224 pub(crate) type_ids: Vec<TypeId>,
225}
226
227impl ComponentPage {
228 /// Adds an entity to this page's entity list.
229 ///
230 /// This method is called by `World::spawn` and is a crucial part of maintaining
231 /// the invariant that the number of rows in the component columns is always
232 /// equal to the number of entities tracked by the page.
233 pub(crate) fn add_entity(&mut self, entity_id: EntityId) {
234 self.entities.push(entity_id);
235 }
236
237 /// Performs a `swap_remove` on a specific row across all component columns
238 /// and the entity list.
239 ///
240 /// This is the core of an O(1) despawn operation. It removes the data for the entity
241 /// at `row_index` by swapping it with the last element in each column and in the
242 /// entity list.
243 ///
244 /// It's the caller's (`World::despawn`) responsibility to update the metadata of
245 /// the entity that was moved from the last row.
246 pub(crate) fn swap_remove_row(&mut self, row_index: u32) {
247 // 1. Remove the corresponding entity ID from the list. `swap_remove` on a Vec
248 // returns the element that was at that index, but we don't need it here.
249 self.entities.swap_remove(row_index as usize);
250
251 // 2. Iterate through all component columns and perform the same swap_remove
252 // on each one, using our `AnyVec` trait.
253 for column in self.columns.values_mut() {
254 column.swap_remove_any(row_index as usize);
255 }
256 }
257
258 /// Returns the number of rows of data (and entities) this page currently stores.
259 #[allow(dead_code)]
260 pub(crate) fn row_count(&self) -> usize {
261 self.entities.len()
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn set_from_bytes_valid_roundtrip() {
271 let original: Vec<u32> = vec![1, 2, 3, 4];
272 let bytes = original.to_bytes();
273 let mut restored: Vec<u32> = Vec::new();
274 // SAFETY: `bytes` was produced by `to_bytes` on a `Vec<u32>`, matching
275 // the element type, size, and alignment of `restored`.
276 unsafe { restored.set_from_bytes(&bytes) }.expect("valid bytes must round-trip");
277 assert_eq!(restored, original);
278 }
279
280 #[test]
281 fn set_from_bytes_misaligned_length_errs() {
282 // 5 bytes is not a multiple of size_of::<u32>() == 4.
283 let bytes = [0u8; 5];
284 let mut col: Vec<u32> = vec![42];
285 // SAFETY: the call validates length before touching memory; we only
286 // assert it rejects a misaligned buffer.
287 let result = unsafe { col.set_from_bytes(&bytes) };
288 assert!(matches!(
289 result,
290 Err(SetFromBytesError::MisalignedLength {
291 len: 5,
292 elem_size: 4
293 })
294 ));
295 // The column must be left untouched on error.
296 assert_eq!(col, vec![42]);
297 }
298
299 #[test]
300 fn set_from_bytes_oversized_payload_errs() {
301 // A length that is a valid multiple of the element size but exceeds the
302 // ceiling. We build a fake slice header without allocating the bytes.
303 let elem_size = std::mem::size_of::<u8>();
304 let oversized_len = MAX_COLUMN_PAYLOAD_BYTES + elem_size;
305 // SAFETY: we never read through this pointer — `set_from_bytes`
306 // validates the length and returns `Err` before any access. The dangling
307 // pointer is only used to construct a slice whose length triggers the
308 // ceiling check.
309 let fake = unsafe {
310 std::slice::from_raw_parts(std::ptr::NonNull::<u8>::dangling().as_ptr(), oversized_len)
311 };
312 let mut col: Vec<u8> = Vec::new();
313 // SAFETY: as above — the oversized length short-circuits with an error
314 // before the slice contents are ever touched.
315 let result = unsafe { col.set_from_bytes(fake) };
316 assert!(matches!(
317 result,
318 Err(SetFromBytesError::PayloadTooLarge { .. })
319 ));
320 }
321
322 #[test]
323 fn set_from_bytes_zero_sized_type_is_noop() {
324 let mut col: Vec<()> = Vec::new();
325 // SAFETY: ZST columns carry no byte payload; the call returns early.
326 let result = unsafe { col.set_from_bytes(&[]) };
327 assert!(result.is_ok());
328 }
329}