Skip to main content

concinnity_core/blob/
schema.rs

1// The blob record schema: the component defs stream and the resource records
2// stream, postcard-serialized together as the `BlobMeta` block the header's
3// meta_len measures. Interpretation of the records (discriminant -> component
4// type, resource_kind -> table) belongs to the runtime registry, not here --
5// these are containers, not meaning.
6
7use crate::ecs::PayloadLocator;
8use crate::ecs::asset_id::AssetId;
9use alloc::vec::Vec;
10
11#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
12/// One component record in the blob's def stream.
13pub struct BlobAssetDef {
14    /// The asset's interned identity. `None` for unnamed runtime-only assets.
15    /// Injected into the component at load time via `Component::inject_name`.
16    pub name: Option<AssetId>,
17    /// The record's asset kind. Always [`AssetKind::Component`].
18    pub kind: AssetKind,
19    /// The component type's registry tag.
20    pub discriminant: u8,
21    /// The serialized runtime component (cook already ran the asset -> component
22    /// translation), loaded via `Component::from_baked`. Every record is baked;
23    /// the transitional authored-args record kind is retired.
24    #[serde(with = "serde_bytes")]
25    pub args_bytes: Vec<u8>,
26    /// Where the component's compiled payload lives, when it has one.
27    pub payload: Option<PayloadLocator>,
28}
29
30/// The blob carries only components: every system is internal client code,
31/// constructed at runtime from world content, never serialized. This kind is
32/// kept as the single discriminator the blob format records per asset.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34pub enum AssetKind {
35    /// A runtime component.
36    Component,
37}
38
39/// The kinds of resource the runtime keeps in per-kind tables, one dense handle
40/// space per kind. The `#[repr(u8)]` discriminant is the resource stream's
41/// `resource_kind` tag (like `ComponentTag` for components); cook writes it and
42/// the runtime selects the table by it. Order is the assignment order cook uses.
43#[repr(u8)]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum ResourceKind {
46    /// Static mesh geometry.
47    Mesh,
48    /// A 2D texture.
49    Texture,
50    /// A baked material.
51    Material,
52    /// A font atlas.
53    Font,
54    /// A decoded audio clip.
55    AudioClip,
56    /// A cubemap texture.
57    CubemapTexture,
58    /// A prefiltered environment map.
59    EnvironmentMap,
60    /// A colour lookup table.
61    ColorLut,
62    /// Skinned mesh geometry.
63    SkinnedMesh,
64}
65
66/// One entry in the blob's resource stream: a compiled resource addressed by its
67/// dense per-kind handle, carried alongside the component stream. `resource_kind`
68/// selects the per-kind table (`ResourceKind as u8`); `handle` is the dense index
69/// within that kind (== the record's position within its kind). A payload
70/// resource (mesh, texture, audio clip) carries a `PayloadLocator` into the blob
71/// payload section; a data resource (a baked Material) carries its runtime bytes
72/// in `data_bytes`. Both fields are present so either shape round-trips; a given
73/// kind uses one branch (AudioClip uses `payload`).
74#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
75pub struct ResourceRecord {
76    /// Which per-kind table this record belongs to (`ResourceKind as u8`).
77    pub resource_kind: u8,
78    /// Dense index within that kind.
79    pub handle: u32,
80    /// Where the compiled payload lives, for a payload resource.
81    pub payload: Option<PayloadLocator>,
82    #[serde(with = "serde_bytes")]
83    /// The runtime bytes, for a data resource.
84    pub data_bytes: Vec<u8>,
85}
86
87/// A verified summary of the blob's shape, produced by cook from the final
88/// record streams and carried alongside them in the metadata block. The runtime
89/// trusts it (debug builds re-derive and assert it matches): the per-type
90/// counts pre-size the ECS columns before the bulk component load, and
91/// `max_blob_index` names the overflow files without scanning either stream.
92/// Anything further (type presence, feature flags) is deliberately not
93/// duplicated here: it is a counts lookup away.
94#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
95pub struct WorldManifest {
96    /// (component discriminant, record count), ascending, nonzero counts only.
97    pub component_counts: Vec<(u8, u32)>,
98    /// Highest blob index any payload locator references; 0 = no overflow.
99    pub max_blob_index: u32,
100}
101
102impl WorldManifest {
103    /// Derive the manifest from the final record streams. Cook builds the
104    /// shipped manifest with this (so it is consistent by construction); the
105    /// runtime re-derives it in debug builds to assert the shipped copy
106    /// matches.
107    pub fn from_records(defs: &[BlobAssetDef], resources: &[ResourceRecord]) -> Self {
108        let mut counts = [0u32; 256];
109        for def in defs {
110            counts[def.discriminant as usize] += 1;
111        }
112        let component_counts = counts
113            .iter()
114            .enumerate()
115            .filter(|&(_, &n)| n > 0)
116            .map(|(d, &n)| (d as u8, n))
117            .collect();
118        let max_blob_index = defs
119            .iter()
120            .filter_map(|d| d.payload.as_ref())
121            .chain(resources.iter().filter_map(|r| r.payload.as_ref()))
122            .map(|p| p.blob_index)
123            .max()
124            .unwrap_or(0);
125        WorldManifest {
126            component_counts,
127            max_blob_index,
128        }
129    }
130}
131
132/// The blob's metadata section: the component stream, the resource stream, and
133/// the manifest summarizing them, postcard-serialized together as the block the
134/// header's `meta_len` measures. Folding everything into one block keeps the
135/// 16-byte header and every payload-offset computation
136/// (`payload_section_start`, the lock's `payload_bytes`) unchanged; only the
137/// block's contents grew. Blob 0 carries the full metadata; overflow blobs
138/// carry an empty `BlobMeta` (whose default manifest is consistent with its
139/// empty streams).
140#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
141pub struct BlobMeta {
142    /// The component stream.
143    pub defs: Vec<BlobAssetDef>,
144    /// The resource stream.
145    pub resources: Vec<ResourceRecord>,
146    /// Verified summary of both streams.
147    pub manifest: WorldManifest,
148    /// Per-scene exclusive content, in scene declaration order.
149    pub scene_groups: Vec<SceneGroup>,
150    /// Baked geometry summaries, keyed by mesh-source handle.
151    pub mesh_bounds: Vec<MeshBoundsRecord>,
152    /// The world's physics reservation, or `None` when it declares no physics.
153    pub physics_budget: Option<PhysicsBudgetRecord>,
154}
155
156/// The bodies a world's physics reserves, counted by cook from the authored
157/// content and grouped by the kind of body the simulation builds for it. The
158/// runtime reserves exactly this at load and refuses to exceed it; debug builds
159/// re-derive it from the loaded components and assert the two agree.
160///
161/// A plain record on purpose: this crate is the container format and knows
162/// nothing about simulation, so the conversion to and from the simulation's own
163/// budget type lives with the driver that reads it.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
165pub struct PhysicsBudgetRecord {
166    /// Immovable bodies: the world's static colliders plus its floor.
167    pub fixed: u32,
168    /// Freely simulated bodies.
169    pub dynamic: u32,
170    /// Position-driven bodies: the player capsule and the character rigs.
171    pub kinematic: u32,
172    /// Sensor bodies, one per trigger volume.
173    pub sensors: u32,
174    /// Joints connecting two bodies.
175    pub joints: u32,
176    /// Hidden static bodies minted to anchor a world-anchored joint.
177    pub anchors: u32,
178    /// Bodies held back for props created after load.
179    pub spawn_headroom: u32,
180}
181
182/// Baked geometry summary of one static mesh payload, keyed by its unified
183/// mesh-source handle. Lets the runtime build draw records (AABB) and size
184/// geometry reservations (counts) without decoding the payload; a payload with
185/// no record decodes eagerly.
186#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
187pub struct MeshBoundsRecord {
188    /// The mesh-source handle this record summarizes.
189    pub handle: u32,
190    /// Lower corner of the mesh's local AABB.
191    pub min: [f32; 3],
192    /// Upper corner of the mesh's local AABB.
193    pub max: [f32; 3],
194    /// Vertices in the payload.
195    pub vertex_count: u32,
196    /// Indices in the payload.
197    pub index_count: u32,
198}
199
200/// One scene's exclusively-owned blob content: the resource-stream entries and
201/// payload-carrying component defs reachable only from that scene's members.
202/// Content shared between scenes (or used outside any scene) belongs to no
203/// group and loads with the world. Groups are listed in scene declaration
204/// order; their payloads are packed into dedicated blobs after the global set.
205#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
206pub struct SceneGroup {
207    /// The scene that exclusively owns this content.
208    pub scene: AssetId,
209    /// (resource_kind, handle) pairs from the resource stream.
210    pub resources: Vec<(u8, u32)>,
211    /// Names of payload-carrying component defs.
212    pub defs: Vec<AssetId>,
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use alloc::vec;
219
220    fn sample_meta() -> BlobMeta {
221        let defs = vec![BlobAssetDef {
222            name: Some(AssetId(3)),
223            kind: AssetKind::Component,
224            discriminant: 42,
225            args_bytes: vec![9, 8, 7],
226            payload: Some(PayloadLocator {
227                blob_index: 1,
228                offset: 16,
229                len: 4,
230            }),
231        }];
232        let resources = vec![ResourceRecord {
233            resource_kind: ResourceKind::Material as u8,
234            handle: 5,
235            payload: None,
236            data_bytes: vec![1, 2, 3, 4],
237        }];
238        let manifest = WorldManifest::from_records(&defs, &resources);
239        BlobMeta {
240            defs,
241            resources,
242            manifest,
243            scene_groups: vec![SceneGroup {
244                scene: AssetId(7),
245                resources: vec![(ResourceKind::Material as u8, 5)],
246                defs: vec![AssetId(3)],
247            }],
248            mesh_bounds: vec![MeshBoundsRecord {
249                handle: 2,
250                min: [-1.0, 0.0, -1.0],
251                max: [1.0, 2.0, 1.0],
252                vertex_count: 24,
253                index_count: 36,
254            }],
255            physics_budget: Some(PhysicsBudgetRecord {
256                fixed: 3,
257                dynamic: 2,
258                kinematic: 1,
259                sensors: 1,
260                joints: 2,
261                anchors: 1,
262                spawn_headroom: 16,
263            }),
264        }
265    }
266
267    // The postcard encoding of the metadata block is the on-disk format; every
268    // record type must survive a byte round-trip unchanged.
269    #[test]
270    fn blob_meta_round_trips_through_postcard() {
271        let meta = sample_meta();
272        let bytes = postcard::to_allocvec(&meta).expect("serialize");
273        let back: BlobMeta = postcard::from_bytes(&bytes).expect("deserialize");
274        assert_eq!(back, meta);
275    }
276
277    // Both ResourceRecord branches (payload locator vs inline data bytes)
278    // round-trip: payload resources and data resources share one record shape.
279    #[test]
280    fn resource_record_round_trips_both_branches() {
281        let payload_res = ResourceRecord {
282            resource_kind: ResourceKind::AudioClip as u8,
283            handle: 0,
284            payload: Some(PayloadLocator {
285                blob_index: 0,
286                offset: 0,
287                len: 7,
288            }),
289            data_bytes: Vec::new(),
290        };
291        let data_res = ResourceRecord {
292            resource_kind: ResourceKind::Material as u8,
293            handle: 1,
294            payload: None,
295            data_bytes: vec![0xAA, 0xBB],
296        };
297        for rec in [payload_res, data_res] {
298            let bytes = postcard::to_allocvec(&rec).expect("serialize");
299            let back: ResourceRecord = postcard::from_bytes(&bytes).expect("deserialize");
300            assert_eq!(back, rec);
301        }
302    }
303
304    // The manifest is a pure function of the record streams: per-type counts
305    // ascending with zero-count types omitted, and the highest blob index any
306    // payload locator (component or resource) references.
307    #[test]
308    fn manifest_derives_counts_and_max_blob_index() {
309        let def = |disc: u8, blob_index: u32| BlobAssetDef {
310            name: None,
311            kind: AssetKind::Component,
312            discriminant: disc,
313            args_bytes: Vec::new(),
314            payload: Some(PayloadLocator {
315                blob_index,
316                offset: 0,
317                len: 1,
318            }),
319        };
320        let defs = vec![def(7, 0), def(7, 2), def(3, 1)];
321        let resources = vec![ResourceRecord {
322            resource_kind: ResourceKind::Texture as u8,
323            handle: 0,
324            payload: Some(PayloadLocator {
325                blob_index: 4,
326                offset: 0,
327                len: 1,
328            }),
329            data_bytes: Vec::new(),
330        }];
331        let manifest = WorldManifest::from_records(&defs, &resources);
332        assert_eq!(manifest.component_counts, vec![(3, 1), (7, 2)]);
333        assert_eq!(manifest.max_blob_index, 4, "resource payloads count too");
334
335        let empty = WorldManifest::from_records(&[], &[]);
336        assert_eq!(empty, WorldManifest::default());
337    }
338
339    // The resource stream tag is the enum discriminant; a reorder would silently
340    // re-key every table, so pin the current assignment.
341    #[test]
342    fn resource_kind_discriminants_are_stable() {
343        assert_eq!(ResourceKind::Mesh as u8, 0);
344        assert_eq!(ResourceKind::Texture as u8, 1);
345        assert_eq!(ResourceKind::Material as u8, 2);
346        assert_eq!(ResourceKind::Font as u8, 3);
347        assert_eq!(ResourceKind::AudioClip as u8, 4);
348        assert_eq!(ResourceKind::CubemapTexture as u8, 5);
349        assert_eq!(ResourceKind::EnvironmentMap as u8, 6);
350        assert_eq!(ResourceKind::ColorLut as u8, 7);
351        assert_eq!(ResourceKind::SkinnedMesh as u8, 8);
352    }
353}