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}