use std::fs;
use std::path::Path;
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use concinnity_core::blob::{
MeshBoundsRecord, PhysicsBudgetRecord, SceneGroup, WorldManifest, encode_cnb, payload_section,
};
use concinnity_core::ecs::{BlobAssetDef, BlobMeta, PayloadLocator, ResourceRecord};
pub(crate) use concinnity_host::store::blob::blob_path;
#[cfg(test)]
pub(crate) use concinnity_host::store::blob::read_cnb;
use serde::{Deserialize, Serialize};
pub const LOCK_PATH: &str = "world-lock.json";
#[derive(Debug, Serialize, Deserialize)]
pub struct BlobEntry {
pub path: String,
pub checksum: String,
pub payload_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlobLock {
pub engine_version: String,
pub built_at: String,
pub blobs: Vec<BlobEntry>,
pub assets: Vec<LockedAsset>,
#[serde(default)]
pub resources: Vec<LockedResource>,
pub injected: Vec<LockedInjection>,
#[serde(default)]
pub shadowed: Vec<LockedShadow>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LockedAsset {
pub name: String,
#[serde(default)]
pub id: Option<u32>,
pub kind: String,
pub discriminant: u8,
pub args_hash: String,
pub payload_blob: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LockedInjection {
pub name: String,
#[serde(rename = "type")]
pub asset_type: String,
pub args: serde_json::Value,
pub injected_by: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LockedShadow {
pub name: String,
#[serde(rename = "type")]
pub asset_type: String,
pub generated_by: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LockedResource {
pub name: String,
#[serde(default)]
pub id: Option<u32>,
pub kind: String,
pub handle: u32,
pub args_hash: String,
pub payload_blob: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texture_source: Option<LockedTextureSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mesh_source: Option<LockedMeshSource>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LockedTextureSource {
pub source: String,
pub image_index: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LockedMeshSource {
pub source: String,
pub primitive_index: u32,
pub lod_levels: u32,
pub lod_distances: Vec<f32>,
}
pub struct PackResult {
pub blob_paths: Vec<String>,
}
fn write_cnb(meta: &BlobMeta, payload: &[u8], path: &str) -> std::io::Result<()> {
let image = encode_cnb(concinnity_core::SCHEMA_VERSION, meta, payload)
.map_err(|e| std::io::Error::other(format!("encoding {}: {:?}", path, e)))?;
fs::write(path, image)
}
pub(crate) struct BlobStreams<'a> {
pub(crate) defs: &'a [BlobAssetDef],
pub(crate) resources: &'a [ResourceRecord],
pub(crate) scene_groups: &'a [SceneGroup],
pub(crate) mesh_bounds: &'a [MeshBoundsRecord],
pub(crate) physics_budget: Option<PhysicsBudgetRecord>,
}
pub(crate) fn write_blobs(
streams: BlobStreams<'_>,
blob_payloads: &[Vec<u8>],
primary: &Path,
) -> std::io::Result<PackResult> {
if let Some(dir) = primary.parent().filter(|d| !d.as_os_str().is_empty()) {
fs::create_dir_all(dir)?;
}
let blob_file = |index: u32| {
let path = if index == 0 {
primary.to_path_buf()
} else {
primary
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), Path::to_path_buf)
.join(index.to_string())
};
path.to_string_lossy().into_owned()
};
let primary_meta = || BlobMeta {
defs: streams.defs.to_vec(),
resources: streams.resources.to_vec(),
manifest: WorldManifest::from_records(streams.defs, streams.resources),
scene_groups: streams.scene_groups.to_vec(),
mesh_bounds: streams.mesh_bounds.to_vec(),
physics_budget: streams.physics_budget,
};
let mut blob_paths = Vec::new();
for (idx, payload) in blob_payloads.iter().enumerate() {
let path = blob_file(idx as u32);
let meta = if idx == 0 {
primary_meta()
} else {
BlobMeta::default()
};
write_cnb(&meta, payload, &path)?;
blob_paths.push(path);
}
if blob_payloads.is_empty() {
let primary = blob_file(0);
write_cnb(&primary_meta(), &[], &primary)?;
blob_paths.push(primary);
}
let mut stale = blob_paths.len() as u32;
while fs::remove_file(blob_file(stale)).is_ok() {
stale += 1;
}
Ok(PackResult { blob_paths })
}
pub(crate) const DEFAULT_MAX_BLOB_BYTES: u64 = 1 << 30;
pub(crate) struct PayloadPacker {
max_blob_bytes: u64,
blobs: Vec<Vec<u8>>,
current_blob: u32,
current_offset: u64,
pending_group: bool,
}
impl PayloadPacker {
pub(crate) fn new(max_blob_bytes: u64) -> Self {
Self {
max_blob_bytes,
blobs: vec![Vec::new()],
current_blob: 0,
current_offset: 0,
pending_group: false,
}
}
pub(crate) fn start_group(&mut self) {
self.pending_group = true;
}
pub(crate) fn push(&mut self, data: &[u8]) -> PayloadLocator {
let len = data.len() as u64;
let group_roll = self.pending_group && (self.current_offset > 0 || self.current_blob == 0);
let size_roll = self.current_offset > 0 && self.current_offset + len > self.max_blob_bytes;
if group_roll || size_roll {
self.blobs.push(Vec::new());
self.current_blob += 1;
self.current_offset = 0;
}
self.pending_group = false;
let offset = self.current_offset;
self.blobs[self.current_blob as usize].extend_from_slice(data);
self.current_offset += len;
PayloadLocator {
blob_index: self.current_blob,
offset,
len,
}
}
pub(crate) fn finish(self) -> Vec<Vec<u8>> {
self.blobs
}
}
pub(crate) fn write_lock(
named_defs: &[(&str, &BlobAssetDef)],
resources: &[LockedResource],
injected: &[crate::build_only::InjectedAsset],
shadowed: &[crate::build_only::ShadowedAsset],
blob_paths: &[String],
) -> std::io::Result<()> {
let mut blobs = Vec::new();
for path in blob_paths {
let data = fs::read(path).unwrap_or_default();
let payload_bytes = payload_section(&data).len() as u64;
blobs.push(BlobEntry {
path: path.clone(),
checksum: checksum(&data),
payload_bytes,
});
}
let assets = named_defs
.iter()
.map(|(name, def)| LockedAsset {
name: name.to_string(),
id: def.name.map(|n| n.0),
kind: format!("{:?}", def.kind),
discriminant: def.discriminant,
args_hash: checksum(&def.args_bytes),
payload_blob: def.payload.as_ref().map(|p| p.blob_index),
})
.collect();
let lock = BlobLock {
engine_version: env!("CARGO_PKG_VERSION").to_string(),
built_at: now_iso8601(),
blobs,
assets,
resources: resources.to_vec(),
injected: injected
.iter()
.map(|i| LockedInjection {
name: i.name.clone(),
asset_type: i.asset_type.clone(),
args: i.args.clone(),
injected_by: i.injected_by.to_string(),
})
.collect(),
shadowed: shadowed
.iter()
.map(|s| LockedShadow {
name: s.name.clone(),
asset_type: s.asset_type.clone(),
generated_by: s.generated_by.clone(),
})
.collect(),
};
fs::write(LOCK_PATH, serde_json::to_string_pretty(&lock)?)
}
pub(crate) fn checksum(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
format!("{:x}", h.finalize())
}
fn now_iso8601() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.expect("time format")
}
#[cfg(test)]
pub(crate) mod test_output {
pub(crate) static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) struct StateDir(tempfile::TempDir);
impl StateDir {
pub(crate) fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
concinnity_host::store::paths::set_state_dir(dir.path());
Self(dir)
}
pub(crate) fn data_dir(&self) -> std::path::PathBuf {
self.0.path().join("data")
}
}
impl Drop for StateDir {
fn drop(&mut self) {
concinnity_host::store::paths::clear_state_dir();
}
}
pub(crate) struct LockFile;
impl Drop for LockFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(super::LOCK_PATH);
let _ = std::fs::remove_dir_all(super::LOCK_PATH);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::{AssetKind, asset_id::AssetId};
use test_output::{LockFile, StateDir};
fn locator(blob_index: u32, offset: u64, len: u64) -> PayloadLocator {
PayloadLocator {
blob_index,
offset,
len,
}
}
fn component_def(discriminant: u8, payload: Option<PayloadLocator>) -> BlobAssetDef {
BlobAssetDef {
name: Some(AssetId(discriminant as u32)),
kind: AssetKind::Component,
discriminant,
args_bytes: vec![discriminant, 0xAA],
payload,
}
}
fn primary() -> std::path::PathBuf {
std::path::PathBuf::from(blob_path(0).expect("the scoped state dir is installed"))
}
fn streams<'a>(defs: &'a [BlobAssetDef], resources: &'a [ResourceRecord]) -> BlobStreams<'a> {
BlobStreams {
defs,
resources,
scene_groups: &[],
mesh_bounds: &[],
physics_budget: None,
}
}
#[test]
fn write_blobs_keeps_metadata_in_blob_zero_and_splits_payload_bytes() {
let _guard = test_output::LOCK.lock().unwrap_or_else(|e| e.into_inner());
let state = StateDir::new();
let defs = vec![
component_def(3, Some(locator(0, 0, 3))),
component_def(4, None),
];
let resources = vec![ResourceRecord {
resource_kind: 2,
handle: 0,
payload: Some(locator(1, 0, 4)),
data_bytes: vec![9, 9],
}];
let payloads = vec![vec![1, 2, 3], vec![4, 5, 6, 7]];
let data_dir = state.data_dir();
let paths = write_blobs(streams(&defs, &resources), &payloads, &primary())
.expect("write_blobs")
.blob_paths;
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], data_dir.join("0").to_string_lossy());
assert_eq!(paths[1], data_dir.join("1").to_string_lossy());
let (meta, payload_start) = read_cnb(&paths[0]).expect("blob 0 parses");
assert_eq!(meta.defs.len(), 2);
assert_eq!(meta.defs[0].discriminant, 3);
assert_eq!(meta.resources.len(), 1);
assert_eq!(meta.resources[0].data_bytes, vec![9, 9]);
assert_eq!(
meta.manifest,
WorldManifest::from_records(&defs, &resources),
"the shipped manifest is derived from the streams it summarizes"
);
assert_eq!(&fs::read(&paths[0]).unwrap()[payload_start..], &[1, 2, 3]);
let (overflow_meta, overflow_start) = read_cnb(&paths[1]).expect("blob 1 parses");
assert!(overflow_meta.defs.is_empty());
assert!(overflow_meta.resources.is_empty());
assert_eq!(
&fs::read(&paths[1]).unwrap()[overflow_start..],
&[4, 5, 6, 7]
);
}
#[test]
fn write_blobs_ships_the_physics_budget_in_blob_zero() {
let _guard = test_output::LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _state = StateDir::new();
let defs = vec![component_def(3, None)];
let budget = PhysicsBudgetRecord {
fixed: 3,
dynamic: 2,
kinematic: 1,
sensors: 1,
joints: 2,
anchors: 1,
spawn_headroom: 8,
};
let paths = write_blobs(
BlobStreams {
physics_budget: Some(budget),
..streams(&defs, &[])
},
&[],
&primary(),
)
.expect("write_blobs")
.blob_paths;
let (meta, _) = read_cnb(&paths[0]).expect("blob 0 parses");
assert_eq!(meta.physics_budget, Some(budget));
let paths = write_blobs(streams(&defs, &[]), &[], &primary())
.expect("write_blobs")
.blob_paths;
let (meta, _) = read_cnb(&paths[0]).expect("blob 0 parses");
assert_eq!(meta.physics_budget, None);
}
#[test]
fn write_blobs_removes_stale_overflow_blobs_from_a_larger_build() {
let _guard = test_output::LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _state = StateDir::new();
let defs = vec![component_def(3, None)];
let payloads = vec![vec![1], vec![2], vec![3]];
let first = write_blobs(streams(&defs, &[]), &payloads, &primary())
.expect("write_blobs")
.blob_paths;
assert_eq!(first.len(), 3);
let second = write_blobs(streams(&defs, &[]), &[vec![1]], &primary())
.expect("write_blobs")
.blob_paths;
assert_eq!(second.len(), 1);
assert!(!std::path::Path::new(&first[1]).exists(), "stale blob 1");
assert!(!std::path::Path::new(&first[2]).exists(), "stale blob 2");
}
#[test]
fn write_blobs_without_payloads_still_writes_the_primary_blob() {
let _guard = test_output::LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _state = StateDir::new();
let defs = vec![component_def(5, None)];
let paths = write_blobs(streams(&defs, &[]), &[], &primary())
.expect("write_blobs")
.blob_paths;
assert_eq!(paths.len(), 1, "a payload-less world still ships blob 0");
let (meta, payload_start) = read_cnb(&paths[0]).expect("blob 0 parses");
assert_eq!(meta.defs.len(), 1);
assert_eq!(fs::read(&paths[0]).unwrap().len(), payload_start);
}
#[test]
fn write_blobs_surfaces_a_write_failure() {
let _guard = test_output::LOCK.lock().unwrap_or_else(|e| e.into_inner());
let state = StateDir::new();
fs::create_dir_all(state.data_dir().join("0")).expect("occupy blob 0");
let result = write_blobs(streams(&[component_def(1, None)], &[]), &[], &primary());
assert!(result.is_err(), "an unwritable blob path must fail");
}
#[test]
fn write_blobs_honors_a_named_primary_path() {
let dir = tempfile::tempdir().expect("tempdir");
let primary = dir.path().join("out").join("world");
let defs = vec![component_def(3, None)];
let paths = write_blobs(streams(&defs, &[]), &[vec![1], vec![2]], &primary)
.expect("write_blobs")
.blob_paths;
assert_eq!(paths[0], primary.to_string_lossy());
assert_eq!(paths[1], primary.with_file_name("1").to_string_lossy());
let (meta, _) = read_cnb(&paths[0]).expect("the named blob parses");
assert_eq!(meta.defs.len(), 1);
}
#[test]
fn write_lock_records_blob_checksums_payload_sizes_and_provenance() {
let _guard = test_output::LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _state = StateDir::new();
let _lock_file = LockFile;
let defs = vec![
component_def(3, Some(locator(0, 0, 3))),
component_def(4, None),
];
let paths = write_blobs(streams(&defs, &[]), &[vec![1, 2, 3]], &primary())
.expect("write_blobs")
.blob_paths;
let named: Vec<(&str, &BlobAssetDef)> = vec![("floor", &defs[0]), ("wall", &defs[1])];
let resources = vec![LockedResource {
name: "clip".to_string(),
id: Some(2),
kind: "AudioClip".to_string(),
handle: 2,
args_hash: "ff".to_string(),
payload_blob: None,
..Default::default()
}];
let injected = vec![crate::build_only::InjectedAsset {
name: "debug_hud".to_string(),
asset_type: "DebugHud".to_string(),
args: serde_json::json!({"enabled": true}),
injected_by: "engine",
}];
let shadowed = vec![crate::build_only::ShadowedAsset {
name: "bistro_mat_wood".to_string(),
asset_type: "Material".to_string(),
generated_by: "bistro".to_string(),
args: serde_json::json!({}),
}];
write_lock(&named, &resources, &injected, &shadowed, &paths).expect("write_lock");
let blob_bytes = fs::read(&paths[0]).expect("blob 0 readable");
let written = fs::read_to_string(LOCK_PATH).expect("lock written to the working directory");
let lock: BlobLock = serde_json::from_str(&written).expect("lock is valid json");
assert_eq!(lock.engine_version, env!("CARGO_PKG_VERSION"));
assert!(OffsetDateTime::parse(&lock.built_at, &Rfc3339).is_ok());
assert_eq!(lock.blobs.len(), 1);
assert_eq!(lock.blobs[0].path, paths[0]);
assert_eq!(lock.blobs[0].checksum, checksum(&blob_bytes));
assert_eq!(
lock.blobs[0].payload_bytes, 3,
"payload bytes exclude the header and the metadata section"
);
assert_eq!(lock.assets.len(), 2);
assert_eq!(lock.assets[0].name, "floor");
assert_eq!(lock.assets[0].kind, "Component");
assert_eq!(lock.assets[0].discriminant, 3);
assert_eq!(lock.assets[0].args_hash, checksum(&defs[0].args_bytes));
assert_eq!(lock.assets[0].payload_blob, Some(0));
assert_eq!(lock.assets[1].name, "wall");
assert_eq!(lock.assets[1].payload_blob, None);
assert_eq!(lock.resources[0].name, "clip");
assert_eq!(lock.resources[0].handle, 2);
assert_eq!(lock.injected[0].name, "debug_hud");
assert_eq!(lock.injected[0].args["enabled"], true);
assert_eq!(lock.injected[0].injected_by, "engine");
assert_eq!(lock.shadowed[0].generated_by, "bistro");
}
#[test]
fn write_lock_tolerates_a_missing_blob_file() {
let _guard = test_output::LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _lock_file = LockFile;
let paths = vec!["/no/such/data/0".to_string()];
write_lock(&[], &[], &[], &[], &paths).expect("write_lock");
let written = fs::read_to_string(LOCK_PATH).expect("lock written");
let lock: BlobLock = serde_json::from_str(&written).expect("lock is valid json");
assert_eq!(lock.blobs[0].payload_bytes, 0);
assert_eq!(lock.blobs[0].checksum, checksum(b""));
assert!(lock.assets.is_empty());
}
#[test]
fn packer_appends_within_the_limit() {
let mut p = PayloadPacker::new(100);
let a = p.push(&[1, 2, 3]);
let b = p.push(&[4, 5]);
assert_eq!((a.blob_index, a.offset, a.len), (0, 0, 3));
assert_eq!((b.blob_index, b.offset, b.len), (0, 3, 2));
assert_eq!(p.finish(), vec![vec![1, 2, 3, 4, 5]]);
}
#[test]
fn packer_rolls_to_a_new_blob_at_the_limit() {
let mut p = PayloadPacker::new(4);
let a = p.push(&[1, 2, 3]);
let b = p.push(&[4, 5]);
assert_eq!(a.blob_index, 0);
assert_eq!((b.blob_index, b.offset, b.len), (1, 0, 2));
assert_eq!(p.finish(), vec![vec![1, 2, 3], vec![4, 5]]);
}
#[test]
fn packer_keeps_an_oversized_payload_in_an_empty_blob() {
let mut p = PayloadPacker::new(4);
let a = p.push(&[7; 10]);
assert_eq!((a.blob_index, a.offset, a.len), (0, 0, 10));
let b = p.push(&[1]);
assert_eq!((b.blob_index, b.offset), (1, 0));
}
#[test]
fn packer_zero_length_payload_gets_a_valid_locator() {
let mut p = PayloadPacker::new(8);
let a = p.push(&[]);
let b = p.push(&[1]);
assert_eq!((a.blob_index, a.offset, a.len), (0, 0, 0));
assert_eq!((b.blob_index, b.offset, b.len), (0, 0, 1));
}
#[test]
fn packer_group_boundary_starts_a_fresh_blob() {
let mut p = PayloadPacker::new(1024);
let a = p.push(&[1, 2]);
p.start_group();
let b = p.push(&[3]);
let c = p.push(&[4]);
assert_eq!(a.blob_index, 0);
assert_eq!((b.blob_index, b.offset), (1, 0));
assert_eq!((c.blob_index, c.offset), (1, 1));
assert_eq!(p.finish(), vec![vec![1, 2], vec![3, 4]]);
}
#[test]
fn packer_empty_group_produces_no_blob() {
let mut p = PayloadPacker::new(1024);
let a = p.push(&[1]);
p.start_group();
p.start_group();
let b = p.push(&[2]);
assert_eq!(a.blob_index, 0);
assert_eq!(b.blob_index, 1);
assert_eq!(p.finish().len(), 2);
}
#[test]
fn packer_group_never_lands_in_blob_zero() {
let mut p = PayloadPacker::new(1024);
p.start_group();
let a = p.push(&[1]);
assert_eq!((a.blob_index, a.offset), (1, 0));
assert_eq!(p.finish(), vec![vec![], vec![1]]);
}
#[test]
fn packer_size_rollover_still_applies_within_a_group() {
let mut p = PayloadPacker::new(2);
p.push(&[1]);
p.start_group();
let a = p.push(&[2, 3]);
let b = p.push(&[4]);
assert_eq!(a.blob_index, 1);
assert_eq!(b.blob_index, 2, "group content exceeding the cap rolls on");
}
#[test]
fn checksum_matches_known_sha256_vectors() {
assert_eq!(
checksum(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
checksum(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn now_iso8601_is_rfc3339_parseable() {
let stamp = now_iso8601();
assert!(OffsetDateTime::parse(&stamp, &Rfc3339).is_ok());
}
#[test]
fn blob_lock_serializes_injected_type_field_as_type() {
let lock = BlobLock {
engine_version: "0.0.0".to_string(),
built_at: "2026-01-01T00:00:00Z".to_string(),
blobs: vec![BlobEntry {
path: "data/0".to_string(),
checksum: "00".to_string(),
payload_bytes: 4,
}],
assets: vec![],
resources: vec![
LockedResource {
name: "clip".to_string(),
id: Some(0),
kind: "AudioClip".to_string(),
handle: 0,
args_hash: "00".to_string(),
payload_blob: Some(0),
..Default::default()
},
LockedResource {
name: "wall_tex".to_string(),
id: Some(1),
kind: "Texture".to_string(),
handle: 0,
args_hash: "00".to_string(),
payload_blob: Some(0),
texture_source: Some(LockedTextureSource {
source: "wall.png".to_string(),
image_index: 2,
}),
..Default::default()
},
],
injected: vec![LockedInjection {
name: "debug_hud".to_string(),
asset_type: "DebugHud".to_string(),
args: serde_json::json!({}),
injected_by: "engine".to_string(),
}],
shadowed: vec![LockedShadow {
name: "bistro_mat_wood".to_string(),
asset_type: "Material".to_string(),
generated_by: "bistro".to_string(),
}],
};
let json = serde_json::to_value(&lock).unwrap();
assert_eq!(json["injected"][0]["type"], "DebugHud");
assert!(json["injected"][0].get("asset_type").is_none());
assert_eq!(json["shadowed"][0]["type"], "Material");
assert_eq!(json["shadowed"][0]["generated_by"], "bistro");
assert!(json["resources"][0].get("texture_source").is_none());
assert!(json["resources"][0].get("mesh_source").is_none());
assert_eq!(json["resources"][1]["texture_source"]["source"], "wall.png");
let back: BlobLock = serde_json::from_value(json).unwrap();
assert_eq!(back.injected[0].asset_type, "DebugHud");
assert_eq!(back.blobs[0].payload_bytes, 4);
assert_eq!(back.resources[0].kind, "AudioClip");
assert!(back.resources[0].texture_source.is_none());
let tex = back.resources[1].texture_source.as_ref().unwrap();
assert_eq!(tex.source, "wall.png");
assert_eq!(tex.image_index, 2);
assert_eq!(back.shadowed[0].name, "bistro_mat_wood");
}
#[test]
fn blob_lock_reads_a_lock_without_its_optional_fields() {
let json = serde_json::json!({
"engine_version": "0.0.0",
"built_at": "2026-01-01T00:00:00Z",
"blobs": [],
"assets": [],
"injected": [],
});
let back: BlobLock = serde_json::from_value(json).unwrap();
assert!(back.resources.is_empty());
assert!(back.shadowed.is_empty());
let json = serde_json::json!({
"engine_version": "0.0.0",
"built_at": "2026-01-01T00:00:00Z",
"blobs": [],
"assets": [],
"resources": [{
"name": "tex", "kind": "Texture", "handle": 0,
"args_hash": "", "payload_blob": null,
}],
"injected": [],
});
let back: BlobLock = serde_json::from_value(json).unwrap();
assert!(back.resources[0].texture_source.is_none());
assert!(back.resources[0].mesh_source.is_none());
}
}