Skip to main content

concinnity_cook/
blob.rs

1//! The blob WRITE side (build output): pack compiled payloads + the def table
2//! into .cnb files and emit world-lock.json. The byte format itself -- the
3//! header, the record schema, and the `encode_cnb` image builder -- is owned by
4//! the I/O-free concinnity-blob crate; this file owns the packing POLICY
5//! (payload distribution across overflow blobs, the size ceiling), the lock, and
6//! the writes themselves.
7
8use 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/// Per-blob entry in the lock file
26#[derive(Debug, Serialize, Deserialize)]
27pub struct BlobEntry {
28    /// The blob file's path, relative to the build output.
29    pub path: String,
30    /// sha-256 of the blob file.
31    pub checksum: String,
32    /// Bytes the blob's payload section occupies.
33    pub payload_bytes: u64,
34}
35
36/// The resolved build record written alongside the binary blobs
37/// Human-readable; owned by the build, not the user
38#[derive(Debug, Serialize, Deserialize)]
39pub struct BlobLock {
40    /// Engine version the blob was built with; injected defaults come from the
41    /// engine, so their content can change across versions.
42    pub engine_version: String,
43    /// When the build ran, as an RFC 3339 timestamp.
44    pub built_at: String,
45    /// One entry per blob file written.
46    pub blobs: Vec<BlobEntry>,
47    /// One entry per asset compiled into the def table.
48    pub assets: Vec<LockedAsset>,
49    /// Assets compiled into the blob's resource stream (addressed by a per-kind
50    /// handle) rather than the component def table.
51    #[serde(default)]
52    pub resources: Vec<LockedResource>,
53    /// Assets the build added that have no world.jsonl line (companions and
54    /// engine defaults). Each entry carries its full args so it can be copied
55    /// into world.jsonl verbatim as an override.
56    pub injected: Vec<LockedInjection>,
57    /// Generated assets the world declares its own copy of. The copy won and the
58    /// generated entry was dropped, so the source file no longer drives these;
59    /// recorded to make that override visible rather than silent.
60    #[serde(default)]
61    pub shadowed: Vec<LockedShadow>,
62}
63
64/// One asset as recorded in the lock file
65#[derive(Debug, Serialize, Deserialize)]
66pub struct LockedAsset {
67    /// The asset's declared name.
68    pub name: String,
69    /// The dense interned id the build assigned this name. Lets a process that
70    /// loads the prebuilt blobs (the editor booting without an in-process cook)
71    /// rebuild the name table exactly as the build interned it.
72    #[serde(default)]
73    pub id: Option<u32>,
74    /// The asset's registry type name.
75    pub kind: String,
76    /// The component type's registry tag.
77    pub discriminant: u8,
78    /// sha-256 of the asset's serialized args_bytes
79    pub args_hash: String,
80    /// which blob holds this asset's payload, if any
81    pub payload_blob: Option<u32>,
82}
83
84/// One injected asset as recorded in the lock file
85#[derive(Debug, Serialize, Deserialize)]
86pub struct LockedInjection {
87    /// The injected asset's name.
88    pub name: String,
89    #[serde(rename = "type")]
90    /// The asset's registry type name.
91    pub asset_type: String,
92    /// The args the injection supplied.
93    pub args: serde_json::Value,
94    /// Which expander injected it.
95    pub injected_by: String,
96}
97
98/// One generated asset the world overrides with its own copy. Carries no args:
99/// the copy that won is the world.jsonl line of the same name.
100#[derive(Debug, Serialize, Deserialize)]
101pub struct LockedShadow {
102    /// The shadowed asset's name.
103    pub name: String,
104    #[serde(rename = "type")]
105    /// The asset's registry type name.
106    pub asset_type: String,
107    /// Which expander generated the copy the world overrode.
108    pub generated_by: String,
109}
110
111/// One resource-stream asset as recorded in the lock file. Resource assets have
112/// left the component def table, so they are recorded with their per-kind handle
113/// instead of a component discriminant. `payload_blob` is None for a data
114/// resource (bytes ride inline in the record).
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct LockedResource {
117    /// The resource's declared name.
118    pub name: String,
119    /// The dense interned id the build assigned this name (the same id space
120    /// as `LockedAsset.id`; `handle` is the per-kind index).
121    #[serde(default)]
122    pub id: Option<u32>,
123    /// The resource kind's name.
124    pub kind: String,
125    /// The resource's dense per-kind handle.
126    pub handle: u32,
127    /// sha-256 of the asset's authored args JSON
128    pub args_hash: String,
129    /// which blob holds this resource's payload, if any
130    pub payload_blob: Option<u32>,
131    /// Dev source info mirrored from the build's hot-reload catalogues, so a
132    /// blob boot can reconstruct them without the asset's args (a SceneImport
133    /// product has none the boot can see). Present for every Texture / Mesh
134    /// resource; an empty `source` means nothing to watch.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub texture_source: Option<LockedTextureSource>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    /// The mesh's re-import inputs, for a mesh resource.
139    pub mesh_source: Option<LockedMeshSource>,
140}
141
142/// A texture resource's watchable file source as recorded in the lock file.
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct LockedTextureSource {
145    /// Authored source path; empty when there is nothing to watch.
146    pub source: String,
147    /// Index of the image within the source document.
148    pub image_index: u32,
149}
150
151/// A mesh resource's re-import inputs as recorded in the lock file.
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct LockedMeshSource {
154    /// Authored source path; empty when there is nothing to watch.
155    pub source: String,
156    /// Index of the primitive within the source document.
157    pub primitive_index: u32,
158    /// How many LODs the mesh declares, including LOD0.
159    pub lod_levels: u32,
160    /// Camera distance at which each LOD past 0 takes over.
161    pub lod_distances: Vec<f32>,
162}
163
164/// The result of a build pack: the blobs written and the path of each
165pub struct PackResult {
166    /// Path of each blob file written.
167    pub blob_paths: Vec<String>,
168}
169
170// Encode one blob image and write it out. The format crate performs no I/O, so
171// the write lives here alongside the rest of the build's file output.
172fn 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
178// The record streams one build produces, packed into blob 0's metadata block.
179// Grouped because they travel together: every one of them is derived from the
180// same compiled asset list and is written exactly once.
181pub(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
189// Pack the metadata (component defs + resource records) and their payloads into
190// one or more blobs. The full metadata rides in blob 0; overflow blobs carry an
191// empty metadata section and pure payload bytes.
192pub(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    // Blob 0 is the file named; every overflow blob is its sibling named by
201    // index, which is the layout the runtime reads back.
202    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    // The manifest is derived from the very streams it summarizes, so the
215    // shipped copy is consistent by construction; the runtime re-derives it and
216    // debug-asserts it matches.
217    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    // Remove stale overflow blobs left by a previous, larger build so the
246    // directory matches the manifest exactly.
247    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
255// Size at which the packer rolls over to a fresh blob.
256pub(crate) const DEFAULT_MAX_BLOB_BYTES: u64 = 1 << 30;
257
258// PayloadPacker (build step)
259pub(crate) struct PayloadPacker {
260    max_blob_bytes: u64,
261    blobs: Vec<Vec<u8>>,
262    current_blob: u32,
263    current_offset: u64,
264    // A group boundary was requested; the next push starts a fresh blob.
265    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    // Start a payload group: the next push lands at the start of a blob that
280    // holds no earlier content and is never blob 0 (whose payload section is
281    // read eagerly at startup), so the group's payloads are contiguous and
282    // separately loadable. A group with no pushes produces no blob.
283    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
315// Lock file
316pub(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
379// Helpers
380pub(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// Shared harness for tests that drive the build's file output: a state tree
393// nothing else can see, with the working directory moved into it so a stray
394// relative write cannot reach the crate's own source directory.
395#[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    /// A private tree to build into, with the working directory moved into it.
403    pub(crate) struct Output {
404        // Held for its Drop: the exclusive lock, the working directory, and the
405        // tree the paths below name.
406        _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        /// The lock file this tree's builds write.
429        pub(crate) fn lock_path(&self) -> PathBuf {
430            self.tree.world_lock_path()
431        }
432
433        /// Blob 0 under this tree's `data/`: what a build writes when nothing
434        /// names a file.
435        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    // The record streams of a build that only has components and resources.
466    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        // Blob 0 carries the whole metadata block plus its own payload section.
500        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        // An overflow blob is payload only: no defs, no resources.
513        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    // The physics reservation rides in blob 0's metadata beside the manifest,
523    // so the runtime reads what cook counted without re-deriving it.
524    #[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        // A world with no physics ships no reservation at all.
552        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    // A blob path that cannot be written (here a directory sits where the file
593    // belongs) fails the build rather than shipping a partial data set.
594    #[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    // A named primary blob owns its own directory: blob 0 is the file asked
608    // for, overflow blobs are its siblings by index, and the directory is
609    // created if it does not exist yet.
610    #[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    // A lock is still written when a listed blob is unreadable: the entry
702    // records the empty checksum and no payload bytes rather than failing.
703    #[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        // 3 + 2 exceeds the 4-byte cap, so this payload starts blob 1.
733        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        // A single payload larger than the cap cannot be split, so it stays
743        // in the current (empty) blob rather than rolling forever.
744        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        // The next payload rolls to a fresh blob.
749        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        // Even with no earlier content, a group leaves the eagerly-read blob 0
790        // empty and starts at blob 1.
791        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    // The byte-format round-trip tests live in the concinnity-blob crate with
810    // the encoder; here the tests cover cook's packing policy and the lock.
811
812    #[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        // The lock file is read by humans and tools; the serde rename on
833        // LockedInjection keeps the JSON key `type`, matching world.jsonl.
834        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        // LockedShadow carries the same rename, and names what it overrides.
883        assert_eq!(json["shadowed"][0]["type"], "Material");
884        assert_eq!(json["shadowed"][0]["generated_by"], "bistro");
885
886        // Source info serializes only where present, and round-trips.
887        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        // Locks written before resource and shadow provenance landed have no
905        // `resources` / `shadowed` key; reading one must not fail.
906        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        // A resource recorded before source info landed parses with none.
918        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}