Skip to main content

sectorsync_core/
component.rs

1//! Custom component registry and station-local blob storage.
2
3use std::collections::{BTreeMap, HashMap};
4
5use crate::ids::{ComponentId, EntityHandle};
6use crate::spatial::Vec3;
7
8/// Component codec error used by built-in codecs.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum ComponentCodecError {
11    /// Input length did not match codec expectation.
12    ExpectedBytes {
13        /// Expected byte count.
14        expected: usize,
15        /// Actual byte count.
16        actual: usize,
17    },
18}
19
20impl core::fmt::Display for ComponentCodecError {
21    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
22        match self {
23            Self::ExpectedBytes { expected, actual } => {
24                write!(f, "expected {expected} bytes, got {actual}")
25            }
26        }
27    }
28}
29
30impl std::error::Error for ComponentCodecError {}
31
32/// Typed component codec. Embedders can implement this for their own compact
33/// schema and bit-packing formats.
34pub trait ComponentCodec<T> {
35    /// Encodes `value` into `out`.
36    fn encode(&self, value: &T, out: &mut Vec<u8>) -> Result<(), ComponentCodecError>;
37
38    /// Decodes a value from bytes.
39    fn decode(&self, input: &[u8]) -> Result<T, ComponentCodecError>;
40
41    /// Fixed encoded size when known.
42    fn fixed_size(&self) -> Option<usize> {
43        None
44    }
45}
46
47/// Little-endian `u32` codec.
48#[derive(Clone, Copy, Debug, Default)]
49pub struct U32LeCodec;
50
51impl ComponentCodec<u32> for U32LeCodec {
52    fn encode(&self, value: &u32, out: &mut Vec<u8>) -> Result<(), ComponentCodecError> {
53        out.extend_from_slice(&value.to_le_bytes());
54        Ok(())
55    }
56
57    fn decode(&self, input: &[u8]) -> Result<u32, ComponentCodecError> {
58        let bytes = exact_array::<4>(input)?;
59        Ok(u32::from_le_bytes(bytes))
60    }
61
62    fn fixed_size(&self) -> Option<usize> {
63        Some(4)
64    }
65}
66
67/// Little-endian `f32` codec.
68#[derive(Clone, Copy, Debug, Default)]
69pub struct F32LeCodec;
70
71impl ComponentCodec<f32> for F32LeCodec {
72    fn encode(&self, value: &f32, out: &mut Vec<u8>) -> Result<(), ComponentCodecError> {
73        out.extend_from_slice(&value.to_le_bytes());
74        Ok(())
75    }
76
77    fn decode(&self, input: &[u8]) -> Result<f32, ComponentCodecError> {
78        let bytes = exact_array::<4>(input)?;
79        Ok(f32::from_le_bytes(bytes))
80    }
81
82    fn fixed_size(&self) -> Option<usize> {
83        Some(4)
84    }
85}
86
87/// Little-endian `Vec3` codec.
88#[derive(Clone, Copy, Debug, Default)]
89pub struct Vec3LeCodec;
90
91impl ComponentCodec<Vec3> for Vec3LeCodec {
92    fn encode(&self, value: &Vec3, out: &mut Vec<u8>) -> Result<(), ComponentCodecError> {
93        out.extend_from_slice(&value.x.to_le_bytes());
94        out.extend_from_slice(&value.y.to_le_bytes());
95        out.extend_from_slice(&value.z.to_le_bytes());
96        Ok(())
97    }
98
99    fn decode(&self, input: &[u8]) -> Result<Vec3, ComponentCodecError> {
100        if input.len() != 12 {
101            return Err(ComponentCodecError::ExpectedBytes {
102                expected: 12,
103                actual: input.len(),
104            });
105        }
106        let x = f32::from_le_bytes(input[0..4].try_into().expect("slice length checked"));
107        let y = f32::from_le_bytes(input[4..8].try_into().expect("slice length checked"));
108        let z = f32::from_le_bytes(input[8..12].try_into().expect("slice length checked"));
109        Ok(Vec3 { x, y, z })
110    }
111
112    fn fixed_size(&self) -> Option<usize> {
113        Some(12)
114    }
115}
116
117fn exact_array<const N: usize>(input: &[u8]) -> Result<[u8; N], ComponentCodecError> {
118    if input.len() != N {
119        return Err(ComponentCodecError::ExpectedBytes {
120            expected: N,
121            actual: input.len(),
122        });
123    }
124    let mut out = [0_u8; N];
125    out.copy_from_slice(input);
126    Ok(out)
127}
128
129/// Storage strategy declared by a registered component.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub enum ComponentStorageKind {
132    /// `SectorSync` stores opaque component bytes in a sparse station-local column.
133    SparseBlob,
134    /// Component data lives outside `SectorSync`; the registry only documents it.
135    External,
136}
137
138/// Synchronization behavior declared by a registered component.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum ComponentSyncMode {
141    /// Component is never replicated by `SectorSync`.
142    NotReplicated,
143    /// Component is replicated as delta when dirty.
144    Delta,
145    /// Component is sent as a snapshot when selected by policy.
146    Snapshot,
147    /// Component changes are represented by events.
148    EventOnly,
149}
150
151/// Migration behavior declared by a registered component.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum ComponentMigrationMode {
154    /// Copy component bytes during owner handoff.
155    Copy,
156    /// Drop component bytes during owner handoff.
157    Drop,
158    /// External system owns migration.
159    External,
160}
161
162/// Registered component descriptor.
163#[derive(Clone, Debug, PartialEq, Eq)]
164pub struct ComponentDescriptor {
165    /// Component id used in hot-path records.
166    pub id: ComponentId,
167    /// Stable debug name.
168    pub name: &'static str,
169    /// Storage strategy.
170    pub storage: ComponentStorageKind,
171    /// Synchronization strategy.
172    pub sync: ComponentSyncMode,
173    /// Migration strategy.
174    pub migration: ComponentMigrationMode,
175    /// Maximum accepted blob size in bytes for `SectorSync`-owned storage.
176    pub max_bytes: usize,
177    /// Stable schema hash selected by the embedding application.
178    pub schema_hash: u64,
179}
180
181impl ComponentDescriptor {
182    /// Creates a sparse blob descriptor.
183    pub const fn sparse_blob(
184        id: ComponentId,
185        name: &'static str,
186        sync: ComponentSyncMode,
187        migration: ComponentMigrationMode,
188        max_bytes: usize,
189    ) -> Self {
190        Self {
191            id,
192            name,
193            storage: ComponentStorageKind::SparseBlob,
194            sync,
195            migration,
196            max_bytes,
197            schema_hash: 0,
198        }
199    }
200
201    /// Attaches a stable schema hash to this descriptor.
202    #[must_use]
203    pub const fn with_schema_hash(mut self, schema_hash: u64) -> Self {
204        self.schema_hash = schema_hash;
205        self
206    }
207}
208
209/// Typed component schema descriptor.
210#[derive(Clone, Debug, PartialEq, Eq)]
211pub struct ComponentSchema {
212    /// Component descriptor.
213    pub descriptor: ComponentDescriptor,
214    /// Fixed encoded size when known.
215    pub fixed_size: Option<usize>,
216}
217
218impl ComponentSchema {
219    /// Creates a typed schema from a descriptor and codec.
220    pub fn new<T, C: ComponentCodec<T>>(descriptor: ComponentDescriptor, codec: &C) -> Self {
221        Self {
222            descriptor,
223            fixed_size: codec.fixed_size(),
224        }
225    }
226}
227
228/// Fixed field type used by generated component schema helpers.
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum ComponentFieldType {
231    /// Unsigned 8-bit integer.
232    U8,
233    /// Unsigned 16-bit little-endian integer.
234    U16,
235    /// Unsigned 32-bit little-endian integer.
236    U32,
237    /// Unsigned 64-bit little-endian integer.
238    U64,
239    /// Signed 32-bit little-endian integer.
240    I32,
241    /// 32-bit little-endian floating point value.
242    F32,
243    /// Three little-endian `f32` values.
244    Vec3,
245    /// Opaque bytes with a maximum generated layout size.
246    Bytes {
247        /// Maximum byte count reserved in the generated layout.
248        max_len: usize,
249    },
250}
251
252impl ComponentFieldType {
253    /// Maximum encoded size in bytes.
254    pub const fn max_size(self) -> usize {
255        match self {
256            Self::U8 => 1,
257            Self::U16 => 2,
258            Self::U32 | Self::I32 | Self::F32 => 4,
259            Self::U64 => 8,
260            Self::Vec3 => 12,
261            Self::Bytes { max_len } => max_len,
262        }
263    }
264
265    const fn tag(self) -> u8 {
266        match self {
267            Self::U8 => 1,
268            Self::U16 => 2,
269            Self::U32 => 3,
270            Self::U64 => 4,
271            Self::I32 => 5,
272            Self::F32 => 6,
273            Self::Vec3 => 7,
274            Self::Bytes { .. } => 8,
275        }
276    }
277}
278
279/// Field descriptor emitted by external schema generators.
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281pub struct ComponentFieldDescriptor {
282    /// Stable field name.
283    pub name: &'static str,
284    /// Encoded field type.
285    pub ty: ComponentFieldType,
286    /// Byte offset inside the generated component blob.
287    pub offset: usize,
288}
289
290impl ComponentFieldDescriptor {
291    /// Creates a generated field descriptor.
292    pub const fn new(name: &'static str, ty: ComponentFieldType, offset: usize) -> Self {
293        Self { name, ty, offset }
294    }
295
296    /// Returns the exclusive end offset.
297    pub const fn end_offset(self) -> usize {
298        self.offset.saturating_add(self.ty.max_size())
299    }
300}
301
302/// Component schema shape emitted by an external generator.
303#[derive(Clone, Copy, Debug, PartialEq, Eq)]
304pub struct GeneratedComponentSchema {
305    /// Component id used in hot-path records.
306    pub id: ComponentId,
307    /// Stable debug name.
308    pub name: &'static str,
309    /// Storage strategy.
310    pub storage: ComponentStorageKind,
311    /// Synchronization strategy.
312    pub sync: ComponentSyncMode,
313    /// Migration strategy.
314    pub migration: ComponentMigrationMode,
315    /// Maximum accepted blob size in bytes.
316    pub max_bytes: usize,
317    /// Generated field layout.
318    pub fields: &'static [ComponentFieldDescriptor],
319}
320
321impl GeneratedComponentSchema {
322    /// Creates a generated component schema.
323    pub const fn new(
324        id: ComponentId,
325        name: &'static str,
326        storage: ComponentStorageKind,
327        sync: ComponentSyncMode,
328        migration: ComponentMigrationMode,
329        max_bytes: usize,
330        fields: &'static [ComponentFieldDescriptor],
331    ) -> Self {
332        Self {
333            id,
334            name,
335            storage,
336            sync,
337            migration,
338            max_bytes,
339            fields,
340        }
341    }
342
343    /// Validates generated layout invariants.
344    pub fn validate(&self) -> Result<(), ComponentSchemaError> {
345        for (index, field) in self.fields.iter().enumerate() {
346            if field.end_offset() > self.max_bytes {
347                return Err(ComponentSchemaError::FieldOutOfBounds {
348                    name: field.name,
349                    offset: field.offset,
350                    size: field.ty.max_size(),
351                    max_bytes: self.max_bytes,
352                });
353            }
354
355            for earlier in &self.fields[..index] {
356                if earlier.name == field.name {
357                    return Err(ComponentSchemaError::DuplicateFieldName(field.name));
358                }
359                if ranges_overlap(
360                    earlier.offset,
361                    earlier.end_offset(),
362                    field.offset,
363                    field.end_offset(),
364                ) {
365                    return Err(ComponentSchemaError::FieldOverlap {
366                        left: earlier.name,
367                        right: field.name,
368                    });
369                }
370            }
371        }
372        Ok(())
373    }
374
375    /// Returns the generated schema hash.
376    pub fn schema_hash(&self) -> u64 {
377        let mut hash = FNV_OFFSET;
378        hash = hash_u64(hash, self.id.get().into());
379        hash = hash_str(hash, self.name);
380        hash = hash_u8(hash, storage_tag(self.storage));
381        hash = hash_u8(hash, sync_tag(self.sync));
382        hash = hash_u8(hash, migration_tag(self.migration));
383        hash = hash_u64(hash, self.max_bytes as u64);
384        for field in self.fields {
385            hash = hash_str(hash, field.name);
386            hash = hash_u8(hash, field.ty.tag());
387            hash = hash_u64(hash, field.ty.max_size() as u64);
388            hash = hash_u64(hash, field.offset as u64);
389        }
390        hash
391    }
392
393    /// Returns the maximum generated fixed size.
394    pub fn fixed_size(&self) -> Option<usize> {
395        self.fields
396            .iter()
397            .map(|field| field.end_offset())
398            .max()
399            .or(Some(0))
400    }
401
402    /// Builds a component descriptor with the generated schema hash attached.
403    pub fn descriptor(&self) -> ComponentDescriptor {
404        ComponentDescriptor {
405            id: self.id,
406            name: self.name,
407            storage: self.storage,
408            sync: self.sync,
409            migration: self.migration,
410            max_bytes: self.max_bytes,
411            schema_hash: self.schema_hash(),
412        }
413    }
414
415    /// Builds a typed component schema wrapper.
416    pub fn component_schema(&self) -> ComponentSchema {
417        ComponentSchema {
418            descriptor: self.descriptor(),
419            fixed_size: self.fixed_size(),
420        }
421    }
422}
423
424/// Generated component schema validation error.
425#[derive(Clone, Copy, Debug, PartialEq, Eq)]
426pub enum ComponentSchemaError {
427    /// Field name was repeated.
428    DuplicateFieldName(&'static str),
429    /// Field range exceeded `max_bytes`.
430    FieldOutOfBounds {
431        /// Field name.
432        name: &'static str,
433        /// Field offset.
434        offset: usize,
435        /// Field size.
436        size: usize,
437        /// Maximum component bytes.
438        max_bytes: usize,
439    },
440    /// Two field byte ranges overlap.
441    FieldOverlap {
442        /// Earlier field.
443        left: &'static str,
444        /// Later field.
445        right: &'static str,
446    },
447}
448
449impl core::fmt::Display for ComponentSchemaError {
450    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
451        match self {
452            Self::DuplicateFieldName(name) => write!(f, "duplicate component field {name}"),
453            Self::FieldOutOfBounds {
454                name,
455                offset,
456                size,
457                max_bytes,
458            } => write!(
459                f,
460                "component field {name} at {offset} with size {size} exceeds max bytes {max_bytes}"
461            ),
462            Self::FieldOverlap { left, right } => {
463                write!(f, "component fields {left} and {right} overlap")
464            }
465        }
466    }
467}
468
469impl std::error::Error for ComponentSchemaError {}
470
471/// Component registry error.
472#[derive(Clone, Debug, PartialEq, Eq)]
473pub enum ComponentRegistryError {
474    /// Component id is already registered.
475    DuplicateId(ComponentId),
476    /// Component name is already registered.
477    DuplicateName(&'static str),
478}
479
480impl core::fmt::Display for ComponentRegistryError {
481    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
482        match self {
483            Self::DuplicateId(id) => write!(f, "duplicate component id {}", id.get()),
484            Self::DuplicateName(name) => write!(f, "duplicate component name {name}"),
485        }
486    }
487}
488
489impl std::error::Error for ComponentRegistryError {}
490
491/// Error produced while registering a generated schema.
492#[derive(Clone, Debug, PartialEq, Eq)]
493pub enum GeneratedSchemaRegistrationError {
494    /// Generated schema validation failed.
495    Schema(ComponentSchemaError),
496    /// Component registry rejected the generated descriptor.
497    Registry(ComponentRegistryError),
498}
499
500impl core::fmt::Display for GeneratedSchemaRegistrationError {
501    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
502        match self {
503            Self::Schema(error) => write!(f, "{error}"),
504            Self::Registry(error) => write!(f, "{error}"),
505        }
506    }
507}
508
509impl std::error::Error for GeneratedSchemaRegistrationError {
510    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
511        match self {
512            Self::Schema(error) => Some(error),
513            Self::Registry(error) => Some(error),
514        }
515    }
516}
517
518/// Dense component descriptor registry.
519#[derive(Clone, Debug, Default)]
520pub struct ComponentRegistry {
521    descriptors: Vec<Option<ComponentDescriptor>>,
522}
523
524impl ComponentRegistry {
525    /// Registers a component descriptor.
526    pub fn register(
527        &mut self,
528        descriptor: ComponentDescriptor,
529    ) -> Result<(), ComponentRegistryError> {
530        if self.get(descriptor.id).is_some() {
531            return Err(ComponentRegistryError::DuplicateId(descriptor.id));
532        }
533        if self.iter().any(|existing| existing.name == descriptor.name) {
534            return Err(ComponentRegistryError::DuplicateName(descriptor.name));
535        }
536
537        let index = usize::from(descriptor.id.get());
538        if self.descriptors.len() <= index {
539            self.descriptors.resize(index + 1, None);
540        }
541        self.descriptors[index] = Some(descriptor);
542        Ok(())
543    }
544
545    /// Validates and registers a generated component schema.
546    pub fn register_generated_schema(
547        &mut self,
548        schema: &GeneratedComponentSchema,
549    ) -> Result<ComponentSchema, GeneratedSchemaRegistrationError> {
550        schema
551            .validate()
552            .map_err(GeneratedSchemaRegistrationError::Schema)?;
553        let component_schema = schema.component_schema();
554        self.register(component_schema.descriptor.clone())
555            .map_err(GeneratedSchemaRegistrationError::Registry)?;
556        Ok(component_schema)
557    }
558
559    /// Gets a descriptor by component id.
560    pub fn get(&self, id: ComponentId) -> Option<&ComponentDescriptor> {
561        self.descriptors
562            .get(usize::from(id.get()))
563            .and_then(Option::as_ref)
564    }
565
566    /// Iterates over descriptors.
567    pub fn iter(&self) -> impl Iterator<Item = &ComponentDescriptor> {
568        self.descriptors.iter().filter_map(Option::as_ref)
569    }
570
571    /// Number of registered descriptors.
572    pub fn len(&self) -> usize {
573        self.iter().count()
574    }
575
576    /// Returns whether no descriptors are registered.
577    pub fn is_empty(&self) -> bool {
578        self.len() == 0
579    }
580}
581
582const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
583const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
584
585fn hash_u8(hash: u64, value: u8) -> u64 {
586    (hash ^ u64::from(value)).wrapping_mul(FNV_PRIME)
587}
588
589fn hash_u64(mut hash: u64, value: u64) -> u64 {
590    for byte in value.to_le_bytes() {
591        hash = hash_u8(hash, byte);
592    }
593    hash
594}
595
596fn hash_str(mut hash: u64, value: &str) -> u64 {
597    for byte in value.bytes() {
598        hash = hash_u8(hash, byte);
599    }
600    hash_u8(hash, 0)
601}
602
603fn storage_tag(storage: ComponentStorageKind) -> u8 {
604    match storage {
605        ComponentStorageKind::SparseBlob => 1,
606        ComponentStorageKind::External => 2,
607    }
608}
609
610fn sync_tag(sync: ComponentSyncMode) -> u8 {
611    match sync {
612        ComponentSyncMode::NotReplicated => 0,
613        ComponentSyncMode::Delta => 1,
614        ComponentSyncMode::Snapshot => 2,
615        ComponentSyncMode::EventOnly => 3,
616    }
617}
618
619fn migration_tag(migration: ComponentMigrationMode) -> u8 {
620    match migration {
621        ComponentMigrationMode::Copy => 1,
622        ComponentMigrationMode::Drop => 2,
623        ComponentMigrationMode::External => 3,
624    }
625}
626
627fn ranges_overlap(
628    left_start: usize,
629    left_end: usize,
630    right_start: usize,
631    right_end: usize,
632) -> bool {
633    left_start < right_end && right_start < left_end
634}
635
636/// Opaque component blob stored in a station-local component column.
637#[derive(Clone, Debug, Default, PartialEq, Eq)]
638pub struct ComponentBlob {
639    /// Monotonic version selected by the writer.
640    pub version: u64,
641    /// Dirty flag used by replication planners.
642    pub dirty: bool,
643    /// Opaque bytes.
644    pub bytes: Vec<u8>,
645}
646
647/// Caller-owned reusable storage for typed component encoding.
648#[derive(Clone, Debug, Default)]
649pub struct ComponentEncodeScratch {
650    bytes: Vec<u8>,
651}
652
653impl ComponentEncodeScratch {
654    /// Creates empty encoding scratch.
655    pub const fn new() -> Self {
656        Self { bytes: Vec::new() }
657    }
658
659    /// Creates encoding scratch with an initial byte capacity.
660    pub fn with_capacity(capacity: usize) -> Self {
661        Self {
662            bytes: Vec::with_capacity(capacity),
663        }
664    }
665
666    /// Retained byte capacity available to subsequent encodes.
667    pub fn retained_capacity(&self) -> usize {
668        self.bytes.capacity()
669    }
670}
671
672/// Component storage error.
673#[derive(Clone, Copy, Debug, PartialEq, Eq)]
674pub enum ComponentStoreError {
675    /// Descriptor does not use `SectorSync`-owned blob storage.
676    NotBlobStorage(ComponentId),
677    /// Blob exceeds descriptor limit.
678    BlobTooLarge {
679        /// Component id.
680        component_id: ComponentId,
681        /// Blob size in bytes.
682        actual: usize,
683        /// Maximum allowed size in bytes.
684        max: usize,
685    },
686    /// Codec failed while encoding or decoding.
687    Codec(ComponentCodecError),
688    /// Component blob does not exist.
689    MissingBlob(ComponentId),
690}
691
692impl core::fmt::Display for ComponentStoreError {
693    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
694        match self {
695            Self::NotBlobStorage(id) => {
696                write!(f, "component {} is not SectorSync blob storage", id.get())
697            }
698            Self::BlobTooLarge {
699                component_id,
700                actual,
701                max,
702            } => write!(
703                f,
704                "component {} blob has {} bytes, max {}",
705                component_id.get(),
706                actual,
707                max
708            ),
709            Self::Codec(error) => write!(f, "{error}"),
710            Self::MissingBlob(id) => write!(f, "component {} blob is missing", id.get()),
711        }
712    }
713}
714
715impl std::error::Error for ComponentStoreError {}
716
717#[derive(Clone, Debug, Default)]
718struct ComponentColumn {
719    values: HashMap<EntityHandle, ComponentBlob>,
720}
721
722/// Station-local sparse component blob store.
723#[derive(Clone, Debug, Default)]
724pub struct ComponentStore {
725    dense_columns: Vec<Option<ComponentColumn>>,
726    sparse_columns: BTreeMap<ComponentId, ComponentColumn>,
727}
728
729const DENSE_COMPONENT_COLUMN_LIMIT: usize = 256;
730
731impl ComponentStore {
732    /// Reserves sparse blob entries for one component column.
733    pub fn reserve_component(&mut self, component_id: ComponentId, additional_entities: usize) {
734        self.column_mut(component_id)
735            .values
736            .reserve(additional_entities);
737    }
738
739    /// Component column slots currently retained without another allocation.
740    pub fn column_slots_capacity(&self) -> usize {
741        self.dense_columns
742            .capacity()
743            .saturating_add(self.sparse_columns.len())
744    }
745
746    /// Sparse entity entries retained for one component without another rehash.
747    pub fn component_capacity(&self, component_id: ComponentId) -> usize {
748        self.column(component_id)
749            .map_or(0, |column| column.values.capacity())
750    }
751
752    /// Writes an opaque component blob.
753    pub fn set_blob(
754        &mut self,
755        descriptor: &ComponentDescriptor,
756        entity: EntityHandle,
757        version: u64,
758        bytes: Vec<u8>,
759    ) -> Result<(), ComponentStoreError> {
760        validate_blob_write(descriptor, bytes.len())?;
761
762        let column = self.column_mut(descriptor.id);
763        column.values.insert(
764            entity,
765            ComponentBlob {
766                version,
767                dirty: true,
768                bytes,
769            },
770        );
771        Ok(())
772    }
773
774    /// Copies an opaque component value into retained blob storage.
775    ///
776    /// Existing blob byte capacity is reused when sufficient. Validation runs
777    /// before mutation, so failed writes leave the previous value unchanged.
778    pub fn set_blob_from_slice(
779        &mut self,
780        descriptor: &ComponentDescriptor,
781        entity: EntityHandle,
782        version: u64,
783        bytes: &[u8],
784    ) -> Result<(), ComponentStoreError> {
785        validate_blob_write(descriptor, bytes.len())?;
786        let column = self.column_mut(descriptor.id);
787        if let Some(blob) = column.values.get_mut(&entity) {
788            blob.bytes.clear();
789            blob.bytes.extend_from_slice(bytes);
790            blob.version = version;
791            blob.dirty = true;
792        } else {
793            column.values.insert(
794                entity,
795                ComponentBlob {
796                    version,
797                    dirty: true,
798                    bytes: bytes.to_vec(),
799                },
800            );
801        }
802        Ok(())
803    }
804
805    /// Encodes and writes a typed component value using `codec`.
806    pub fn set_typed<T, C: ComponentCodec<T>>(
807        &mut self,
808        descriptor: &ComponentDescriptor,
809        entity: EntityHandle,
810        version: u64,
811        codec: &C,
812        value: &T,
813    ) -> Result<(), ComponentStoreError> {
814        let mut bytes = Vec::with_capacity(codec.fixed_size().unwrap_or(0));
815        codec
816            .encode(value, &mut bytes)
817            .map_err(ComponentStoreError::Codec)?;
818        self.set_blob(descriptor, entity, version, bytes)
819    }
820
821    /// Encodes a typed value through caller-owned scratch and copies it into
822    /// retained blob storage.
823    pub fn set_typed_with_scratch<T, C: ComponentCodec<T>>(
824        &mut self,
825        descriptor: &ComponentDescriptor,
826        entity: EntityHandle,
827        version: u64,
828        codec: &C,
829        value: &T,
830        scratch: &mut ComponentEncodeScratch,
831    ) -> Result<(), ComponentStoreError> {
832        scratch.bytes.clear();
833        if let Some(size) = codec.fixed_size() {
834            scratch.bytes.reserve(size);
835        }
836        codec
837            .encode(value, &mut scratch.bytes)
838            .map_err(ComponentStoreError::Codec)?;
839        self.set_blob_from_slice(descriptor, entity, version, &scratch.bytes)
840    }
841
842    /// Gets an opaque component blob.
843    pub fn get_blob(
844        &self,
845        component_id: ComponentId,
846        entity: EntityHandle,
847    ) -> Option<&ComponentBlob> {
848        self.column(component_id)
849            .and_then(|column| column.values.get(&entity))
850    }
851
852    /// Returns whether any selected component on `entity` is dirty.
853    ///
854    /// This is a low-level convenience for caller-owned replication eligibility
855    /// rules; the store does not clear dirty state or track per-client delivery.
856    pub fn has_dirty_selected(&self, entity: EntityHandle, component_ids: &[ComponentId]) -> bool {
857        component_ids.iter().any(|component_id| {
858            self.get_blob(*component_id, entity)
859                .is_some_and(|blob| blob.dirty)
860        })
861    }
862
863    /// Decodes a typed component value using `codec`.
864    pub fn get_typed<T, C: ComponentCodec<T>>(
865        &self,
866        component_id: ComponentId,
867        entity: EntityHandle,
868        codec: &C,
869    ) -> Result<T, ComponentStoreError> {
870        let blob = self
871            .get_blob(component_id, entity)
872            .ok_or(ComponentStoreError::MissingBlob(component_id))?;
873        codec
874            .decode(&blob.bytes)
875            .map_err(ComponentStoreError::Codec)
876    }
877
878    /// Gets a mutable opaque component blob.
879    pub fn get_blob_mut(
880        &mut self,
881        component_id: ComponentId,
882        entity: EntityHandle,
883    ) -> Option<&mut ComponentBlob> {
884        let index = usize::from(component_id.get());
885        if index < DENSE_COMPONENT_COLUMN_LIMIT {
886            self.dense_columns
887                .get_mut(index)
888                .and_then(Option::as_mut)
889                .and_then(|column| column.values.get_mut(&entity))
890        } else {
891            self.sparse_columns
892                .get_mut(&component_id)
893                .and_then(|column| column.values.get_mut(&entity))
894        }
895    }
896
897    /// Clears dirty flags for all components on one entity.
898    pub fn clear_dirty_for_entity(&mut self, entity: EntityHandle) -> usize {
899        let mut cleared = 0;
900        for column in self
901            .dense_columns
902            .iter_mut()
903            .filter_map(Option::as_mut)
904            .chain(self.sparse_columns.values_mut())
905        {
906            if let Some(blob) = column.values.get_mut(&entity)
907                && blob.dirty
908            {
909                blob.dirty = false;
910                cleared += 1;
911            }
912        }
913        cleared
914    }
915
916    /// Removes all component blobs for an entity and returns the removed values.
917    pub fn remove_entity(&mut self, entity: EntityHandle) -> Vec<(ComponentId, ComponentBlob)> {
918        let mut removed = Vec::new();
919        self.remove_entity_into(entity, &mut removed);
920        removed
921    }
922
923    /// Removes all component blobs into caller-owned reusable output storage.
924    pub fn remove_entity_into(
925        &mut self,
926        entity: EntityHandle,
927        removed: &mut Vec<(ComponentId, ComponentBlob)>,
928    ) -> usize {
929        removed.clear();
930        for (index, column) in self.dense_columns.iter_mut().enumerate() {
931            let Some(column) = column else {
932                continue;
933            };
934            if let Some(blob) = column.values.remove(&entity) {
935                removed.push((
936                    ComponentId::new(u16::try_from(index).expect("dense component id fits u16")),
937                    blob,
938                ));
939            }
940        }
941        for (component_id, column) in &mut self.sparse_columns {
942            if let Some(blob) = column.values.remove(&entity) {
943                removed.push((*component_id, blob));
944            }
945        }
946        removed.len()
947    }
948
949    /// Removes and drops all component blobs for an entity without output storage.
950    pub fn clear_entity(&mut self, entity: EntityHandle) -> usize {
951        let mut removed = 0_usize;
952        for column in self
953            .dense_columns
954            .iter_mut()
955            .filter_map(Option::as_mut)
956            .chain(self.sparse_columns.values_mut())
957        {
958            removed = removed.saturating_add(usize::from(column.values.remove(&entity).is_some()));
959        }
960        removed
961    }
962
963    /// Copies migratable component blobs from one entity handle to another.
964    pub fn copy_for_migration(
965        &mut self,
966        registry: &ComponentRegistry,
967        source: EntityHandle,
968        target: EntityHandle,
969    ) -> usize {
970        let mut copied = 0;
971        for descriptor in registry.iter() {
972            if descriptor.migration != ComponentMigrationMode::Copy {
973                continue;
974            }
975            let Some(blob) = self.get_blob(descriptor.id, source).cloned() else {
976                continue;
977            };
978            self.column_mut(descriptor.id).values.insert(target, blob);
979            copied += 1;
980        }
981        copied
982    }
983
984    /// Returns number of component blobs stored in all columns.
985    pub fn blob_count(&self) -> usize {
986        self.dense_columns
987            .iter()
988            .filter_map(Option::as_ref)
989            .chain(self.sparse_columns.values())
990            .map(|column| column.values.len())
991            .sum()
992    }
993
994    fn column_mut(&mut self, component_id: ComponentId) -> &mut ComponentColumn {
995        let index = usize::from(component_id.get());
996        if index < DENSE_COMPONENT_COLUMN_LIMIT {
997            if self.dense_columns.len() <= index {
998                self.dense_columns.resize_with(index + 1, || None);
999            }
1000            self.dense_columns[index].get_or_insert_with(ComponentColumn::default)
1001        } else {
1002            self.sparse_columns.entry(component_id).or_default()
1003        }
1004    }
1005
1006    fn column(&self, component_id: ComponentId) -> Option<&ComponentColumn> {
1007        let index = usize::from(component_id.get());
1008        if index < DENSE_COMPONENT_COLUMN_LIMIT {
1009            self.dense_columns.get(index).and_then(Option::as_ref)
1010        } else {
1011            self.sparse_columns.get(&component_id)
1012        }
1013    }
1014
1015    /// Number of component columns currently registered in the store.
1016    pub fn column_count(&self) -> usize {
1017        self.dense_columns
1018            .iter()
1019            .filter(|column| column.is_some())
1020            .count()
1021            + self.sparse_columns.len()
1022    }
1023}
1024
1025fn validate_blob_write(
1026    descriptor: &ComponentDescriptor,
1027    bytes: usize,
1028) -> Result<(), ComponentStoreError> {
1029    if descriptor.storage != ComponentStorageKind::SparseBlob {
1030        return Err(ComponentStoreError::NotBlobStorage(descriptor.id));
1031    }
1032    if bytes > descriptor.max_bytes {
1033        return Err(ComponentStoreError::BlobTooLarge {
1034            component_id: descriptor.id,
1035            actual: bytes,
1036            max: descriptor.max_bytes,
1037        });
1038    }
1039    Ok(())
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045
1046    #[test]
1047    fn component_column_capacity_is_explicit_and_observable() {
1048        let component_id = ComponentId::new(3);
1049        let mut store = ComponentStore::default();
1050        store.reserve_component(component_id, 16);
1051
1052        assert!(store.column_slots_capacity() >= 1);
1053        assert_eq!(store.column_count(), 1);
1054        assert!(store.component_capacity(component_id) >= 16);
1055
1056        let descriptor = ComponentDescriptor::sparse_blob(
1057            component_id,
1058            "reserved",
1059            ComponentSyncMode::Delta,
1060            ComponentMigrationMode::Copy,
1061            4,
1062        );
1063        let handle = EntityHandle::new(1, 0);
1064        store
1065            .set_blob(&descriptor, handle, 1, vec![1, 2, 3, 4])
1066            .expect("reserved component should write");
1067        assert_eq!(
1068            store
1069                .get_blob(component_id, handle)
1070                .map(|blob| blob.bytes.as_slice()),
1071            Some(&[1, 2, 3, 4][..])
1072        );
1073    }
1074
1075    #[test]
1076    fn sparse_component_ids_only_allocate_registered_columns() {
1077        let low = ComponentId::new(1);
1078        let high = ComponentId::new(u16::MAX);
1079        let mut store = ComponentStore::default();
1080        store.reserve_component(high, 4);
1081        let capacity_after_high = store.column_slots_capacity();
1082        store.reserve_component(low, 4);
1083
1084        assert_eq!(store.column_count(), 2);
1085        assert!(capacity_after_high < 16);
1086        assert!(store.column_slots_capacity() < 16);
1087        assert!(store.component_capacity(low) >= 4);
1088        assert!(store.component_capacity(high) >= 4);
1089    }
1090
1091    #[test]
1092    fn slice_updates_reuse_blob_storage_and_reject_oversized_values_atomically() {
1093        let component_id = ComponentId::new(4);
1094        let descriptor = ComponentDescriptor::sparse_blob(
1095            component_id,
1096            "state",
1097            ComponentSyncMode::Delta,
1098            ComponentMigrationMode::Copy,
1099            8,
1100        );
1101        let handle = EntityHandle::new(1, 0);
1102        let mut store = ComponentStore::default();
1103        store
1104            .set_blob(&descriptor, handle, 1, vec![0; 8])
1105            .expect("initial blob should fit");
1106        let retained_bytes = store
1107            .get_blob(component_id, handle)
1108            .expect("blob exists")
1109            .bytes
1110            .as_ptr();
1111
1112        store
1113            .set_blob_from_slice(&descriptor, handle, 2, &[1, 2, 3, 4])
1114            .expect("slice update should fit");
1115        let blob = store.get_blob(component_id, handle).expect("blob exists");
1116        assert_eq!(blob.bytes, [1, 2, 3, 4]);
1117        assert_eq!(blob.bytes.as_ptr(), retained_bytes);
1118        assert_eq!(blob.version, 2);
1119        assert!(blob.dirty);
1120
1121        assert_eq!(
1122            store
1123                .set_blob_from_slice(&descriptor, handle, 3, &[9; 9])
1124                .expect_err("oversized update should fail"),
1125            ComponentStoreError::BlobTooLarge {
1126                component_id,
1127                actual: 9,
1128                max: 8,
1129            }
1130        );
1131        let blob = store.get_blob(component_id, handle).expect("blob remains");
1132        assert_eq!(blob.bytes, [1, 2, 3, 4]);
1133        assert_eq!(blob.version, 2);
1134    }
1135
1136    #[test]
1137    fn registry_rejects_duplicate_ids_and_names() {
1138        let mut registry = ComponentRegistry::default();
1139        let descriptor = ComponentDescriptor::sparse_blob(
1140            ComponentId::new(1),
1141            "health",
1142            ComponentSyncMode::Delta,
1143            ComponentMigrationMode::Copy,
1144            16,
1145        );
1146
1147        registry
1148            .register(descriptor.clone())
1149            .expect("first registration should work");
1150        assert_eq!(
1151            registry
1152                .register(descriptor.clone())
1153                .expect_err("duplicate id"),
1154            ComponentRegistryError::DuplicateId(ComponentId::new(1))
1155        );
1156        assert_eq!(
1157            registry
1158                .register(ComponentDescriptor::sparse_blob(
1159                    ComponentId::new(2),
1160                    "health",
1161                    ComponentSyncMode::Delta,
1162                    ComponentMigrationMode::Copy,
1163                    16,
1164                ))
1165                .expect_err("duplicate name"),
1166            ComponentRegistryError::DuplicateName("health")
1167        );
1168    }
1169
1170    #[test]
1171    fn component_store_sets_clears_and_migrates_blobs() {
1172        let descriptor = ComponentDescriptor::sparse_blob(
1173            ComponentId::new(1),
1174            "health",
1175            ComponentSyncMode::Delta,
1176            ComponentMigrationMode::Copy,
1177            16,
1178        );
1179        let mut registry = ComponentRegistry::default();
1180        registry
1181            .register(descriptor.clone())
1182            .expect("descriptor should register");
1183        let mut store = ComponentStore::default();
1184        let source = EntityHandle::new(1, 0);
1185        let target = EntityHandle::new(2, 0);
1186
1187        store
1188            .set_blob(&descriptor, source, 7, vec![1, 2, 3])
1189            .expect("blob should fit");
1190        assert!(
1191            store
1192                .get_blob(ComponentId::new(1), source)
1193                .expect("blob")
1194                .dirty
1195        );
1196        assert_eq!(store.clear_dirty_for_entity(source), 1);
1197        assert!(
1198            !store
1199                .get_blob(ComponentId::new(1), source)
1200                .expect("blob")
1201                .dirty
1202        );
1203
1204        assert_eq!(store.copy_for_migration(&registry, source, target), 1);
1205        assert_eq!(
1206            store
1207                .get_blob(ComponentId::new(1), target)
1208                .expect("target blob")
1209                .bytes,
1210            vec![1, 2, 3]
1211        );
1212    }
1213
1214    #[test]
1215    fn component_entity_removal_supports_owned_reusable_and_discard_paths() {
1216        let descriptors = [
1217            ComponentDescriptor::sparse_blob(
1218                ComponentId::new(1),
1219                "health",
1220                ComponentSyncMode::Delta,
1221                ComponentMigrationMode::Copy,
1222                8,
1223            ),
1224            ComponentDescriptor::sparse_blob(
1225                ComponentId::new(2),
1226                "armor",
1227                ComponentSyncMode::Delta,
1228                ComponentMigrationMode::Copy,
1229                8,
1230            ),
1231        ];
1232        let entities = [
1233            EntityHandle::new(1, 0),
1234            EntityHandle::new(2, 0),
1235            EntityHandle::new(3, 0),
1236            EntityHandle::new(4, 0),
1237        ];
1238        let mut store = ComponentStore::default();
1239        for entity in entities {
1240            for descriptor in &descriptors {
1241                store
1242                    .set_blob(
1243                        descriptor,
1244                        entity,
1245                        1,
1246                        vec![u8::try_from(descriptor.id.get()).expect("small component id"); 4],
1247                    )
1248                    .expect("bounded blob should write");
1249            }
1250        }
1251
1252        let owned = store.remove_entity(entities[0]);
1253        assert_eq!(owned.len(), 2);
1254        assert_eq!(owned[0].0, ComponentId::new(1));
1255        assert_eq!(owned[1].0, ComponentId::new(2));
1256
1257        let mut removed = Vec::with_capacity(2);
1258        let retained_pointer = removed.as_ptr();
1259        assert_eq!(store.remove_entity_into(entities[1], &mut removed), 2);
1260        assert_eq!(removed.as_ptr(), retained_pointer);
1261        let retained_capacity = removed.capacity();
1262        assert_eq!(store.remove_entity_into(entities[2], &mut removed), 2);
1263        assert_eq!(removed.as_ptr(), retained_pointer);
1264        assert_eq!(removed.capacity(), retained_capacity);
1265
1266        assert_eq!(store.clear_entity(entities[3]), 2);
1267        assert_eq!(store.clear_entity(entities[3]), 0);
1268        assert_eq!(store.blob_count(), 0);
1269    }
1270
1271    #[test]
1272    fn typed_component_codec_roundtrips_values() {
1273        let descriptor = ComponentDescriptor::sparse_blob(
1274            ComponentId::new(3),
1275            "velocity",
1276            ComponentSyncMode::Delta,
1277            ComponentMigrationMode::Copy,
1278            12,
1279        )
1280        .with_schema_hash(0xABCD);
1281        let schema = ComponentSchema::new(descriptor.clone(), &Vec3LeCodec);
1282        assert_eq!(schema.fixed_size, Some(12));
1283        assert_eq!(schema.descriptor.schema_hash, 0xABCD);
1284
1285        let mut store = ComponentStore::default();
1286        let entity = EntityHandle::new(7, 0);
1287        let value = Vec3::new(1.0, 2.0, 3.5);
1288
1289        store
1290            .set_typed(&descriptor, entity, 1, &Vec3LeCodec, &value)
1291            .expect("typed set should work");
1292        let decoded = store
1293            .get_typed(ComponentId::new(3), entity, &Vec3LeCodec)
1294            .expect("typed get should work");
1295        assert_eq!(decoded, value);
1296    }
1297
1298    #[test]
1299    fn typed_component_scratch_reuses_encoding_and_blob_capacity() {
1300        let component_id = ComponentId::new(5);
1301        let descriptor = ComponentDescriptor::sparse_blob(
1302            component_id,
1303            "score",
1304            ComponentSyncMode::Delta,
1305            ComponentMigrationMode::Copy,
1306            4,
1307        );
1308        let entity = EntityHandle::new(7, 0);
1309        let mut store = ComponentStore::default();
1310        let mut scratch = ComponentEncodeScratch::new();
1311
1312        store
1313            .set_typed_with_scratch(&descriptor, entity, 1, &U32LeCodec, &10, &mut scratch)
1314            .expect("initial typed write should work");
1315        let retained_scratch = scratch.retained_capacity();
1316        let retained_blob = store
1317            .get_blob(component_id, entity)
1318            .expect("blob exists")
1319            .bytes
1320            .as_ptr();
1321        store
1322            .set_typed_with_scratch(&descriptor, entity, 2, &U32LeCodec, &20, &mut scratch)
1323            .expect("repeated typed write should work");
1324
1325        assert_eq!(scratch.retained_capacity(), retained_scratch);
1326        assert_eq!(
1327            store
1328                .get_blob(component_id, entity)
1329                .expect("blob exists")
1330                .bytes
1331                .as_ptr(),
1332            retained_blob
1333        );
1334        assert_eq!(
1335            store
1336                .get_typed(component_id, entity, &U32LeCodec)
1337                .expect("typed value decodes"),
1338            20
1339        );
1340    }
1341
1342    #[test]
1343    fn selected_dirty_query_only_considers_requested_components() {
1344        let selected = ComponentDescriptor::sparse_blob(
1345            ComponentId::new(1),
1346            "selected",
1347            ComponentSyncMode::Delta,
1348            ComponentMigrationMode::Copy,
1349            4,
1350        );
1351        let ignored = ComponentDescriptor::sparse_blob(
1352            ComponentId::new(2),
1353            "ignored",
1354            ComponentSyncMode::Delta,
1355            ComponentMigrationMode::Copy,
1356            4,
1357        );
1358        let entity = EntityHandle::new(1, 0);
1359        let mut store = ComponentStore::default();
1360        store
1361            .set_blob(&selected, entity, 1, vec![1])
1362            .expect("selected blob should fit");
1363        store
1364            .set_blob(&ignored, entity, 1, vec![2])
1365            .expect("ignored blob should fit");
1366
1367        assert!(store.has_dirty_selected(entity, &[selected.id]));
1368        store
1369            .get_blob_mut(selected.id, entity)
1370            .expect("selected blob exists")
1371            .dirty = false;
1372        assert!(!store.has_dirty_selected(entity, &[selected.id]));
1373        assert!(store.has_dirty_selected(entity, &[ignored.id]));
1374        assert!(!store.has_dirty_selected(entity, &[]));
1375    }
1376
1377    #[test]
1378    fn generated_schema_builds_descriptor_and_registers() {
1379        const FIELDS: &[ComponentFieldDescriptor] = &[
1380            ComponentFieldDescriptor::new("position", ComponentFieldType::Vec3, 0),
1381            ComponentFieldDescriptor::new("health", ComponentFieldType::U32, 12),
1382        ];
1383        let generated = GeneratedComponentSchema::new(
1384            ComponentId::new(8),
1385            "unit_state",
1386            ComponentStorageKind::SparseBlob,
1387            ComponentSyncMode::Delta,
1388            ComponentMigrationMode::Copy,
1389            16,
1390            FIELDS,
1391        );
1392
1393        generated.validate().expect("schema should be valid");
1394        assert_eq!(generated.fixed_size(), Some(16));
1395        assert_ne!(generated.schema_hash(), 0);
1396
1397        let descriptor = generated.descriptor();
1398        assert_eq!(descriptor.id, ComponentId::new(8));
1399        assert_eq!(descriptor.schema_hash, generated.schema_hash());
1400
1401        let mut registry = ComponentRegistry::default();
1402        let schema = registry
1403            .register_generated_schema(&generated)
1404            .expect("generated schema should register");
1405        assert_eq!(schema.fixed_size, Some(16));
1406        assert_eq!(
1407            registry
1408                .get(ComponentId::new(8))
1409                .expect("registered descriptor")
1410                .schema_hash,
1411            generated.schema_hash()
1412        );
1413    }
1414
1415    #[test]
1416    fn generated_schema_validation_rejects_bad_layouts() {
1417        const DUP_FIELDS: &[ComponentFieldDescriptor] = &[
1418            ComponentFieldDescriptor::new("x", ComponentFieldType::U32, 0),
1419            ComponentFieldDescriptor::new("x", ComponentFieldType::U32, 4),
1420        ];
1421        const OVERLAP_FIELDS: &[ComponentFieldDescriptor] = &[
1422            ComponentFieldDescriptor::new("left", ComponentFieldType::U32, 0),
1423            ComponentFieldDescriptor::new("right", ComponentFieldType::U32, 2),
1424        ];
1425        const OOB_FIELDS: &[ComponentFieldDescriptor] = &[ComponentFieldDescriptor::new(
1426            "wide",
1427            ComponentFieldType::U64,
1428            4,
1429        )];
1430
1431        let duplicate = GeneratedComponentSchema::new(
1432            ComponentId::new(1),
1433            "duplicate",
1434            ComponentStorageKind::SparseBlob,
1435            ComponentSyncMode::Delta,
1436            ComponentMigrationMode::Copy,
1437            8,
1438            DUP_FIELDS,
1439        );
1440        assert_eq!(
1441            duplicate.validate().expect_err("duplicate should fail"),
1442            ComponentSchemaError::DuplicateFieldName("x")
1443        );
1444
1445        let overlap = GeneratedComponentSchema::new(
1446            ComponentId::new(2),
1447            "overlap",
1448            ComponentStorageKind::SparseBlob,
1449            ComponentSyncMode::Delta,
1450            ComponentMigrationMode::Copy,
1451            8,
1452            OVERLAP_FIELDS,
1453        );
1454        assert_eq!(
1455            overlap.validate().expect_err("overlap should fail"),
1456            ComponentSchemaError::FieldOverlap {
1457                left: "left",
1458                right: "right"
1459            }
1460        );
1461
1462        let out_of_bounds = GeneratedComponentSchema::new(
1463            ComponentId::new(3),
1464            "oob",
1465            ComponentStorageKind::SparseBlob,
1466            ComponentSyncMode::Delta,
1467            ComponentMigrationMode::Copy,
1468            8,
1469            OOB_FIELDS,
1470        );
1471        assert_eq!(
1472            out_of_bounds
1473                .validate()
1474                .expect_err("out of bounds should fail"),
1475            ComponentSchemaError::FieldOutOfBounds {
1476                name: "wide",
1477                offset: 4,
1478                size: 8,
1479                max_bytes: 8
1480            }
1481        );
1482    }
1483}