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}