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