1use std::fs;
9use std::path::Path;
10
11use sha2::{Digest, Sha256};
12use time::OffsetDateTime;
13use time::format_description::well_known::Rfc3339;
14
15use concinnity_core::blob::{
16 MeshBoundsRecord, PhysicsBudgetRecord, SceneGroup, WorldManifest, encode_cnb, payload_section,
17};
18use concinnity_core::ecs::{BlobAssetDef, BlobMeta, PayloadLocator, ResourceRecord};
19
20#[cfg(test)]
21pub(crate) use concinnity_host::store::blob::read_cnb;
22
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Serialize, Deserialize)]
27pub struct BlobEntry {
28 pub path: String,
30 pub checksum: String,
32 pub payload_bytes: u64,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
39pub struct BlobLock {
40 pub engine_version: String,
43 pub built_at: String,
45 pub blobs: Vec<BlobEntry>,
47 pub assets: Vec<LockedAsset>,
49 #[serde(default)]
52 pub resources: Vec<LockedResource>,
53 pub injected: Vec<LockedInjection>,
57 #[serde(default)]
61 pub shadowed: Vec<LockedShadow>,
62}
63
64#[derive(Debug, Serialize, Deserialize)]
66pub struct LockedAsset {
67 pub name: String,
69 #[serde(default)]
73 pub id: Option<u32>,
74 pub kind: String,
76 pub discriminant: u8,
78 pub args_hash: String,
80 pub payload_blob: Option<u32>,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
86pub struct LockedInjection {
87 pub name: String,
89 #[serde(rename = "type")]
90 pub asset_type: String,
92 pub args: serde_json::Value,
94 pub injected_by: String,
96}
97
98#[derive(Debug, Serialize, Deserialize)]
101pub struct LockedShadow {
102 pub name: String,
104 #[serde(rename = "type")]
105 pub asset_type: String,
107 pub generated_by: String,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct LockedResource {
117 pub name: String,
119 #[serde(default)]
122 pub id: Option<u32>,
123 pub kind: String,
125 pub handle: u32,
127 pub args_hash: String,
129 pub payload_blob: Option<u32>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub texture_source: Option<LockedTextureSource>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub mesh_source: Option<LockedMeshSource>,
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct LockedTextureSource {
145 pub source: String,
147 pub image_index: u32,
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct LockedMeshSource {
154 pub source: String,
156 pub primitive_index: u32,
158 pub lod_levels: u32,
160 pub lod_distances: Vec<f32>,
162}
163
164pub struct PackResult {
166 pub blob_paths: Vec<String>,
168}
169
170fn write_cnb(meta: &BlobMeta, payload: &[u8], path: &str) -> std::io::Result<()> {
173 let image = encode_cnb(concinnity_core::SCHEMA_VERSION, meta, payload)
174 .map_err(|e| std::io::Error::other(format!("encoding {}: {:?}", path, e)))?;
175 fs::write(path, image)
176}
177
178pub(crate) struct BlobStreams<'a> {
182 pub(crate) defs: &'a [BlobAssetDef],
183 pub(crate) resources: &'a [ResourceRecord],
184 pub(crate) scene_groups: &'a [SceneGroup],
185 pub(crate) mesh_bounds: &'a [MeshBoundsRecord],
186 pub(crate) physics_budget: Option<PhysicsBudgetRecord>,
187}
188
189pub(crate) fn write_blobs(
193 streams: BlobStreams<'_>,
194 blob_payloads: &[Vec<u8>],
195 primary: &Path,
196) -> std::io::Result<PackResult> {
197 if let Some(dir) = primary.parent().filter(|d| !d.as_os_str().is_empty()) {
198 fs::create_dir_all(dir)?;
199 }
200 let blob_file = |index: u32| {
203 let path = if index == 0 {
204 primary.to_path_buf()
205 } else {
206 primary
207 .parent()
208 .map_or_else(|| Path::new(".").to_path_buf(), Path::to_path_buf)
209 .join(index.to_string())
210 };
211 path.to_string_lossy().into_owned()
212 };
213
214 let primary_meta = || BlobMeta {
218 defs: streams.defs.to_vec(),
219 resources: streams.resources.to_vec(),
220 manifest: WorldManifest::from_records(streams.defs, streams.resources),
221 scene_groups: streams.scene_groups.to_vec(),
222 mesh_bounds: streams.mesh_bounds.to_vec(),
223 physics_budget: streams.physics_budget,
224 };
225
226 let mut blob_paths = Vec::new();
227
228 for (idx, payload) in blob_payloads.iter().enumerate() {
229 let path = blob_file(idx as u32);
230 let meta = if idx == 0 {
231 primary_meta()
232 } else {
233 BlobMeta::default()
234 };
235 write_cnb(&meta, payload, &path)?;
236 blob_paths.push(path);
237 }
238
239 if blob_payloads.is_empty() {
240 let primary = blob_file(0);
241 write_cnb(&primary_meta(), &[], &primary)?;
242 blob_paths.push(primary);
243 }
244
245 let mut stale = blob_paths.len() as u32;
248 while fs::remove_file(blob_file(stale)).is_ok() {
249 stale += 1;
250 }
251
252 Ok(PackResult { blob_paths })
253}
254
255pub(crate) const DEFAULT_MAX_BLOB_BYTES: u64 = 1 << 30;
257
258pub(crate) struct PayloadPacker {
260 max_blob_bytes: u64,
261 blobs: Vec<Vec<u8>>,
262 current_blob: u32,
263 current_offset: u64,
264 pending_group: bool,
266}
267
268impl PayloadPacker {
269 pub(crate) fn new(max_blob_bytes: u64) -> Self {
270 Self {
271 max_blob_bytes,
272 blobs: vec![Vec::new()],
273 current_blob: 0,
274 current_offset: 0,
275 pending_group: false,
276 }
277 }
278
279 pub(crate) fn start_group(&mut self) {
284 self.pending_group = true;
285 }
286
287 pub(crate) fn push(&mut self, data: &[u8]) -> PayloadLocator {
288 let len = data.len() as u64;
289
290 let group_roll = self.pending_group && (self.current_offset > 0 || self.current_blob == 0);
291 let size_roll = self.current_offset > 0 && self.current_offset + len > self.max_blob_bytes;
292 if group_roll || size_roll {
293 self.blobs.push(Vec::new());
294 self.current_blob += 1;
295 self.current_offset = 0;
296 }
297 self.pending_group = false;
298
299 let offset = self.current_offset;
300 self.blobs[self.current_blob as usize].extend_from_slice(data);
301 self.current_offset += len;
302
303 PayloadLocator {
304 blob_index: self.current_blob,
305 offset,
306 len,
307 }
308 }
309
310 pub(crate) fn finish(self) -> Vec<Vec<u8>> {
311 self.blobs
312 }
313}
314
315pub(crate) fn write_lock(
317 tree: &crate::paths::StateTree,
318 named_defs: &[(&str, &BlobAssetDef)],
319 resources: &[LockedResource],
320 injected: &[crate::build_only::InjectedAsset],
321 shadowed: &[crate::build_only::ShadowedAsset],
322 blob_paths: &[String],
323) -> std::io::Result<()> {
324 let mut blobs = Vec::new();
325 for path in blob_paths {
326 let data = fs::read(path).unwrap_or_default();
327 let payload_bytes = payload_section(&data).len() as u64;
328 blobs.push(BlobEntry {
329 path: path.clone(),
330 checksum: checksum(&data),
331 payload_bytes,
332 });
333 }
334
335 let assets = named_defs
336 .iter()
337 .map(|(name, def)| LockedAsset {
338 name: name.to_string(),
339 id: def.name.map(|n| n.0),
340 kind: format!("{:?}", def.kind),
341 discriminant: def.discriminant,
342 args_hash: checksum(&def.args_bytes),
343 payload_blob: def.payload.as_ref().map(|p| p.blob_index),
344 })
345 .collect();
346
347 let lock = BlobLock {
348 engine_version: env!("CARGO_PKG_VERSION").to_string(),
349 built_at: now_iso8601(),
350 blobs,
351 assets,
352 resources: resources.to_vec(),
353 injected: injected
354 .iter()
355 .map(|i| LockedInjection {
356 name: i.name.clone(),
357 asset_type: i.asset_type.clone(),
358 args: i.args.clone(),
359 injected_by: i.injected_by.to_string(),
360 })
361 .collect(),
362 shadowed: shadowed
363 .iter()
364 .map(|s| LockedShadow {
365 name: s.name.clone(),
366 asset_type: s.asset_type.clone(),
367 generated_by: s.generated_by.clone(),
368 })
369 .collect(),
370 };
371
372 let path = tree.world_lock_path();
373 if let Some(parent) = path.parent() {
374 fs::create_dir_all(parent)?;
375 }
376 fs::write(path, serde_json::to_string_pretty(&lock)?)
377}
378
379pub(crate) fn checksum(data: &[u8]) -> String {
381 let mut h = Sha256::new();
382 h.update(data);
383 hex::encode(h.finalize())
384}
385
386fn now_iso8601() -> String {
387 OffsetDateTime::now_utc()
388 .format(&Rfc3339)
389 .expect("time format")
390}
391
392#[cfg(test)]
396pub(crate) mod test_output {
397 use std::path::PathBuf;
398
399 use concinnity_host::store::paths::StateTree;
400 use concinnity_testing::GlobalState;
401
402 pub(crate) struct Output {
404 _guard: GlobalState,
407 tree: StateTree,
408 }
409
410 impl Output {
411 pub(crate) fn new() -> Self {
412 let guard = GlobalState::acquire().with_cwd();
413 let tree = StateTree::at(guard.root());
414 Self {
415 _guard: guard,
416 tree,
417 }
418 }
419
420 pub(crate) fn tree(&self) -> &StateTree {
421 &self.tree
422 }
423
424 pub(crate) fn data_dir(&self) -> PathBuf {
425 self.tree.data_dir()
426 }
427
428 pub(crate) fn lock_path(&self) -> PathBuf {
430 self.tree.world_lock_path()
431 }
432
433 pub(crate) fn primary(&self) -> PathBuf {
436 concinnity_host::store::blob::primary_in(&self.tree.data_dir())
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::ecs::{AssetKind, asset_id::AssetId};
445 use test_output::Output;
446
447 fn locator(blob_index: u32, offset: u64, len: u64) -> PayloadLocator {
448 PayloadLocator {
449 blob_index,
450 offset,
451 len,
452 }
453 }
454
455 fn component_def(discriminant: u8, payload: Option<PayloadLocator>) -> BlobAssetDef {
456 BlobAssetDef {
457 name: Some(AssetId(discriminant as u32)),
458 kind: AssetKind::Component,
459 discriminant,
460 args_bytes: vec![discriminant, 0xAA],
461 payload,
462 }
463 }
464
465 fn streams<'a>(defs: &'a [BlobAssetDef], resources: &'a [ResourceRecord]) -> BlobStreams<'a> {
467 BlobStreams {
468 defs,
469 resources,
470 scene_groups: &[],
471 mesh_bounds: &[],
472 physics_budget: None,
473 }
474 }
475
476 #[test]
477 fn write_blobs_keeps_metadata_in_blob_zero_and_splits_payload_bytes() {
478 let output = Output::new();
479
480 let defs = vec![
481 component_def(3, Some(locator(0, 0, 3))),
482 component_def(4, None),
483 ];
484 let resources = vec![ResourceRecord {
485 resource_kind: 2,
486 handle: 0,
487 payload: Some(locator(1, 0, 4)),
488 data_bytes: vec![9, 9],
489 }];
490 let payloads = vec![vec![1, 2, 3], vec![4, 5, 6, 7]];
491 let data_dir = output.data_dir();
492 let paths = write_blobs(streams(&defs, &resources), &payloads, &output.primary())
493 .expect("write_blobs")
494 .blob_paths;
495 assert_eq!(paths.len(), 2);
496 assert_eq!(paths[0], data_dir.join("0").to_string_lossy());
497 assert_eq!(paths[1], data_dir.join("1").to_string_lossy());
498
499 let (meta, payload_start) = read_cnb(&paths[0]).expect("blob 0 parses");
501 assert_eq!(meta.defs.len(), 2);
502 assert_eq!(meta.defs[0].discriminant, 3);
503 assert_eq!(meta.resources.len(), 1);
504 assert_eq!(meta.resources[0].data_bytes, vec![9, 9]);
505 assert_eq!(
506 meta.manifest,
507 WorldManifest::from_records(&defs, &resources),
508 "the shipped manifest is derived from the streams it summarizes"
509 );
510 assert_eq!(&fs::read(&paths[0]).unwrap()[payload_start..], &[1, 2, 3]);
511
512 let (overflow_meta, overflow_start) = read_cnb(&paths[1]).expect("blob 1 parses");
514 assert!(overflow_meta.defs.is_empty());
515 assert!(overflow_meta.resources.is_empty());
516 assert_eq!(
517 &fs::read(&paths[1]).unwrap()[overflow_start..],
518 &[4, 5, 6, 7]
519 );
520 }
521
522 #[test]
525 fn write_blobs_ships_the_physics_budget_in_blob_zero() {
526 let output = Output::new();
527
528 let defs = vec![component_def(3, None)];
529 let budget = PhysicsBudgetRecord {
530 fixed: 3,
531 dynamic: 2,
532 kinematic: 1,
533 sensors: 1,
534 joints: 2,
535 anchors: 1,
536 spawn_headroom: 8,
537 };
538 let paths = write_blobs(
539 BlobStreams {
540 physics_budget: Some(budget),
541 ..streams(&defs, &[])
542 },
543 &[],
544 &output.primary(),
545 )
546 .expect("write_blobs")
547 .blob_paths;
548 let (meta, _) = read_cnb(&paths[0]).expect("blob 0 parses");
549 assert_eq!(meta.physics_budget, Some(budget));
550
551 let paths = write_blobs(streams(&defs, &[]), &[], &output.primary())
553 .expect("write_blobs")
554 .blob_paths;
555 let (meta, _) = read_cnb(&paths[0]).expect("blob 0 parses");
556 assert_eq!(meta.physics_budget, None);
557 }
558
559 #[test]
560 fn write_blobs_removes_stale_overflow_blobs_from_a_larger_build() {
561 let output = Output::new();
562
563 let defs = vec![component_def(3, None)];
564 let payloads = vec![vec![1], vec![2], vec![3]];
565 let first = write_blobs(streams(&defs, &[]), &payloads, &output.primary())
566 .expect("write_blobs")
567 .blob_paths;
568 assert_eq!(first.len(), 3);
569
570 let second = write_blobs(streams(&defs, &[]), &[vec![1]], &output.primary())
571 .expect("write_blobs")
572 .blob_paths;
573 assert_eq!(second.len(), 1);
574 assert!(!std::path::Path::new(&first[1]).exists(), "stale blob 1");
575 assert!(!std::path::Path::new(&first[2]).exists(), "stale blob 2");
576 }
577
578 #[test]
579 fn write_blobs_without_payloads_still_writes_the_primary_blob() {
580 let output = Output::new();
581
582 let defs = vec![component_def(5, None)];
583 let paths = write_blobs(streams(&defs, &[]), &[], &output.primary())
584 .expect("write_blobs")
585 .blob_paths;
586 assert_eq!(paths.len(), 1, "a payload-less world still ships blob 0");
587 let (meta, payload_start) = read_cnb(&paths[0]).expect("blob 0 parses");
588 assert_eq!(meta.defs.len(), 1);
589 assert_eq!(fs::read(&paths[0]).unwrap().len(), payload_start);
590 }
591
592 #[test]
595 fn write_blobs_surfaces_a_write_failure() {
596 let output = Output::new();
597 fs::create_dir_all(output.primary()).expect("occupy blob 0");
598
599 let result = write_blobs(
600 streams(&[component_def(1, None)], &[]),
601 &[],
602 &output.primary(),
603 );
604 assert!(result.is_err(), "an unwritable blob path must fail");
605 }
606
607 #[test]
611 fn write_blobs_honors_a_named_primary_path() {
612 let dir = tempfile::tempdir().expect("tempdir");
613 let primary = dir.path().join("out").join("world");
614 let defs = vec![component_def(3, None)];
615
616 let paths = write_blobs(streams(&defs, &[]), &[vec![1], vec![2]], &primary)
617 .expect("write_blobs")
618 .blob_paths;
619
620 assert_eq!(paths[0], primary.to_string_lossy());
621 assert_eq!(paths[1], primary.with_file_name("1").to_string_lossy());
622 let (meta, _) = read_cnb(&paths[0]).expect("the named blob parses");
623 assert_eq!(meta.defs.len(), 1);
624 }
625
626 #[test]
627 fn write_lock_records_blob_checksums_payload_sizes_and_provenance() {
628 let output = Output::new();
629
630 let defs = vec![
631 component_def(3, Some(locator(0, 0, 3))),
632 component_def(4, None),
633 ];
634 let paths = write_blobs(streams(&defs, &[]), &[vec![1, 2, 3]], &output.primary())
635 .expect("write_blobs")
636 .blob_paths;
637 let named: Vec<(&str, &BlobAssetDef)> = vec![("floor", &defs[0]), ("wall", &defs[1])];
638 let resources = vec![LockedResource {
639 name: "clip".to_string(),
640 id: Some(2),
641 kind: "AudioClip".to_string(),
642 handle: 2,
643 args_hash: "ff".to_string(),
644 payload_blob: None,
645 ..Default::default()
646 }];
647 let injected = vec![crate::build_only::InjectedAsset {
648 name: "debug_hud".to_string(),
649 asset_type: "DebugHud".to_string(),
650 args: serde_json::json!({"enabled": true}),
651 injected_by: "engine",
652 }];
653 let shadowed = vec![crate::build_only::ShadowedAsset {
654 name: "bistro_mat_wood".to_string(),
655 asset_type: "Material".to_string(),
656 generated_by: "bistro".to_string(),
657 args: serde_json::json!({}),
658 }];
659
660 write_lock(
661 output.tree(),
662 &named,
663 &resources,
664 &injected,
665 &shadowed,
666 &paths,
667 )
668 .expect("write_lock");
669 let blob_bytes = fs::read(&paths[0]).expect("blob 0 readable");
670 let written = fs::read_to_string(output.lock_path()).expect("lock written into the tree");
671
672 let lock: BlobLock = serde_json::from_str(&written).expect("lock is valid json");
673 assert_eq!(lock.engine_version, env!("CARGO_PKG_VERSION"));
674 assert!(OffsetDateTime::parse(&lock.built_at, &Rfc3339).is_ok());
675
676 assert_eq!(lock.blobs.len(), 1);
677 assert_eq!(lock.blobs[0].path, paths[0]);
678 assert_eq!(lock.blobs[0].checksum, checksum(&blob_bytes));
679 assert_eq!(
680 lock.blobs[0].payload_bytes, 3,
681 "payload bytes exclude the header and the metadata section"
682 );
683
684 assert_eq!(lock.assets.len(), 2);
685 assert_eq!(lock.assets[0].name, "floor");
686 assert_eq!(lock.assets[0].kind, "Component");
687 assert_eq!(lock.assets[0].discriminant, 3);
688 assert_eq!(lock.assets[0].args_hash, checksum(&defs[0].args_bytes));
689 assert_eq!(lock.assets[0].payload_blob, Some(0));
690 assert_eq!(lock.assets[1].name, "wall");
691 assert_eq!(lock.assets[1].payload_blob, None);
692
693 assert_eq!(lock.resources[0].name, "clip");
694 assert_eq!(lock.resources[0].handle, 2);
695 assert_eq!(lock.injected[0].name, "debug_hud");
696 assert_eq!(lock.injected[0].args["enabled"], true);
697 assert_eq!(lock.injected[0].injected_by, "engine");
698 assert_eq!(lock.shadowed[0].generated_by, "bistro");
699 }
700
701 #[test]
704 fn write_lock_tolerates_a_missing_blob_file() {
705 let output = Output::new();
706
707 let paths = vec!["/no/such/data/0".to_string()];
708 write_lock(output.tree(), &[], &[], &[], &[], &paths).expect("write_lock");
709 let written = fs::read_to_string(output.lock_path()).expect("lock written");
710
711 let lock: BlobLock = serde_json::from_str(&written).expect("lock is valid json");
712 assert_eq!(lock.blobs[0].payload_bytes, 0);
713 assert_eq!(lock.blobs[0].checksum, checksum(b""));
714 assert!(lock.assets.is_empty());
715 }
716
717 #[test]
718 fn packer_appends_within_the_limit() {
719 let mut p = PayloadPacker::new(100);
720 let a = p.push(&[1, 2, 3]);
721 let b = p.push(&[4, 5]);
722
723 assert_eq!((a.blob_index, a.offset, a.len), (0, 0, 3));
724 assert_eq!((b.blob_index, b.offset, b.len), (0, 3, 2));
725 assert_eq!(p.finish(), vec![vec![1, 2, 3, 4, 5]]);
726 }
727
728 #[test]
729 fn packer_rolls_to_a_new_blob_at_the_limit() {
730 let mut p = PayloadPacker::new(4);
731 let a = p.push(&[1, 2, 3]);
732 let b = p.push(&[4, 5]);
734
735 assert_eq!(a.blob_index, 0);
736 assert_eq!((b.blob_index, b.offset, b.len), (1, 0, 2));
737 assert_eq!(p.finish(), vec![vec![1, 2, 3], vec![4, 5]]);
738 }
739
740 #[test]
741 fn packer_keeps_an_oversized_payload_in_an_empty_blob() {
742 let mut p = PayloadPacker::new(4);
745 let a = p.push(&[7; 10]);
746 assert_eq!((a.blob_index, a.offset, a.len), (0, 0, 10));
747
748 let b = p.push(&[1]);
750 assert_eq!((b.blob_index, b.offset), (1, 0));
751 }
752
753 #[test]
754 fn packer_zero_length_payload_gets_a_valid_locator() {
755 let mut p = PayloadPacker::new(8);
756 let a = p.push(&[]);
757 let b = p.push(&[1]);
758 assert_eq!((a.blob_index, a.offset, a.len), (0, 0, 0));
759 assert_eq!((b.blob_index, b.offset, b.len), (0, 0, 1));
760 }
761
762 #[test]
763 fn packer_group_boundary_starts_a_fresh_blob() {
764 let mut p = PayloadPacker::new(1024);
765 let a = p.push(&[1, 2]);
766 p.start_group();
767 let b = p.push(&[3]);
768 let c = p.push(&[4]);
769 assert_eq!(a.blob_index, 0);
770 assert_eq!((b.blob_index, b.offset), (1, 0));
771 assert_eq!((c.blob_index, c.offset), (1, 1));
772 assert_eq!(p.finish(), vec![vec![1, 2], vec![3, 4]]);
773 }
774
775 #[test]
776 fn packer_empty_group_produces_no_blob() {
777 let mut p = PayloadPacker::new(1024);
778 let a = p.push(&[1]);
779 p.start_group();
780 p.start_group();
781 let b = p.push(&[2]);
782 assert_eq!(a.blob_index, 0);
783 assert_eq!(b.blob_index, 1);
784 assert_eq!(p.finish().len(), 2);
785 }
786
787 #[test]
788 fn packer_group_never_lands_in_blob_zero() {
789 let mut p = PayloadPacker::new(1024);
792 p.start_group();
793 let a = p.push(&[1]);
794 assert_eq!((a.blob_index, a.offset), (1, 0));
795 assert_eq!(p.finish(), vec![vec![], vec![1]]);
796 }
797
798 #[test]
799 fn packer_size_rollover_still_applies_within_a_group() {
800 let mut p = PayloadPacker::new(2);
801 p.push(&[1]);
802 p.start_group();
803 let a = p.push(&[2, 3]);
804 let b = p.push(&[4]);
805 assert_eq!(a.blob_index, 1);
806 assert_eq!(b.blob_index, 2, "group content exceeding the cap rolls on");
807 }
808
809 #[test]
813 fn checksum_matches_known_sha256_vectors() {
814 assert_eq!(
815 checksum(b""),
816 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
817 );
818 assert_eq!(
819 checksum(b"abc"),
820 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
821 );
822 }
823
824 #[test]
825 fn now_iso8601_is_rfc3339_parseable() {
826 let stamp = now_iso8601();
827 assert!(OffsetDateTime::parse(&stamp, &Rfc3339).is_ok());
828 }
829
830 #[test]
831 fn blob_lock_serializes_injected_type_field_as_type() {
832 let lock = BlobLock {
835 engine_version: "0.0.0".to_string(),
836 built_at: "2026-01-01T00:00:00Z".to_string(),
837 blobs: vec![BlobEntry {
838 path: "data/0".to_string(),
839 checksum: "00".to_string(),
840 payload_bytes: 4,
841 }],
842 assets: vec![],
843 resources: vec![
844 LockedResource {
845 name: "clip".to_string(),
846 id: Some(0),
847 kind: "AudioClip".to_string(),
848 handle: 0,
849 args_hash: "00".to_string(),
850 payload_blob: Some(0),
851 ..Default::default()
852 },
853 LockedResource {
854 name: "wall_tex".to_string(),
855 id: Some(1),
856 kind: "Texture".to_string(),
857 handle: 0,
858 args_hash: "00".to_string(),
859 payload_blob: Some(0),
860 texture_source: Some(LockedTextureSource {
861 source: "wall.png".to_string(),
862 image_index: 2,
863 }),
864 ..Default::default()
865 },
866 ],
867 injected: vec![LockedInjection {
868 name: "debug_hud".to_string(),
869 asset_type: "DebugHud".to_string(),
870 args: serde_json::json!({}),
871 injected_by: "engine".to_string(),
872 }],
873 shadowed: vec![LockedShadow {
874 name: "bistro_mat_wood".to_string(),
875 asset_type: "Material".to_string(),
876 generated_by: "bistro".to_string(),
877 }],
878 };
879 let json = serde_json::to_value(&lock).unwrap();
880 assert_eq!(json["injected"][0]["type"], "DebugHud");
881 assert!(json["injected"][0].get("asset_type").is_none());
882 assert_eq!(json["shadowed"][0]["type"], "Material");
884 assert_eq!(json["shadowed"][0]["generated_by"], "bistro");
885
886 assert!(json["resources"][0].get("texture_source").is_none());
888 assert!(json["resources"][0].get("mesh_source").is_none());
889 assert_eq!(json["resources"][1]["texture_source"]["source"], "wall.png");
890
891 let back: BlobLock = serde_json::from_value(json).unwrap();
892 assert_eq!(back.injected[0].asset_type, "DebugHud");
893 assert_eq!(back.blobs[0].payload_bytes, 4);
894 assert_eq!(back.resources[0].kind, "AudioClip");
895 assert!(back.resources[0].texture_source.is_none());
896 let tex = back.resources[1].texture_source.as_ref().unwrap();
897 assert_eq!(tex.source, "wall.png");
898 assert_eq!(tex.image_index, 2);
899 assert_eq!(back.shadowed[0].name, "bistro_mat_wood");
900 }
901
902 #[test]
903 fn blob_lock_reads_a_lock_without_its_optional_fields() {
904 let json = serde_json::json!({
907 "engine_version": "0.0.0",
908 "built_at": "2026-01-01T00:00:00Z",
909 "blobs": [],
910 "assets": [],
911 "injected": [],
912 });
913 let back: BlobLock = serde_json::from_value(json).unwrap();
914 assert!(back.resources.is_empty());
915 assert!(back.shadowed.is_empty());
916
917 let json = serde_json::json!({
919 "engine_version": "0.0.0",
920 "built_at": "2026-01-01T00:00:00Z",
921 "blobs": [],
922 "assets": [],
923 "resources": [{
924 "name": "tex", "kind": "Texture", "handle": 0,
925 "args_hash": "", "payload_blob": null,
926 }],
927 "injected": [],
928 });
929 let back: BlobLock = serde_json::from_value(json).unwrap();
930 assert!(back.resources[0].texture_source.is_none());
931 assert!(back.resources[0].mesh_source.is_none());
932 }
933}