concinnity_host/store/blob/
mod.rs1use std::fs;
8use std::io::Read;
9use std::path::{Path, PathBuf};
10use std::sync::{Mutex, OnceLock};
11
12pub use concinnity_core::blob::{BLOB_MAGIC, HEADER_SIZE, WorldManifest};
13use concinnity_core::blob::{BlobError, parse_cnb, parse_payload_section_start, payload_section};
14use concinnity_core::result::CnResult;
15
16mod data;
17
18pub use concinnity_core::SCHEMA_VERSION;
19pub use concinnity_core::ecs::{BlobAssetDef, BlobMeta, ResourceRecord};
20pub use data::BlobData;
21
22fn primary_override() -> &'static Mutex<Option<PathBuf>> {
26 static PRIMARY: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
27 PRIMARY.get_or_init(|| Mutex::new(None))
28}
29
30pub fn blob_path(index: u32) -> Option<String> {
40 let primary = primary_override().lock().unwrap().clone();
41 resolve_blob_path(
42 primary.as_deref(),
43 super::paths::data_dir().as_deref(),
44 index,
45 )
46}
47
48fn resolve_blob_path(
51 primary: Option<&Path>,
52 data_dir: Option<&Path>,
53 index: u32,
54) -> Option<String> {
55 let path = match primary {
56 Some(p) if index == 0 => p.to_path_buf(),
57 Some(p) => p
58 .parent()
59 .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
60 .join(index.to_string()),
61 None => data_dir?.join(index.to_string()),
62 };
63 Some(path.to_string_lossy().into_owned())
64}
65
66pub fn read_cnb(path: &str) -> Result<(BlobMeta, usize), CnResult> {
69 let data = read_file(path)?;
70 parse_cnb::<BlobMeta>(SCHEMA_VERSION, &data).map_err(|e| report(path, e))
71}
72
73pub fn payload_section_start(path: &str) -> Result<u64, CnResult> {
79 let mut file = fs::File::open(path).map_err(|e| {
80 tracing::error!("Failed to open {}: {}", path, e);
81 CnResult::FileIo
82 })?;
83 let mut header = [0u8; HEADER_SIZE];
84 file.read_exact(&mut header).map_err(|e| {
85 tracing::error!("Failed to read header of {}: {}", path, e);
86 CnResult::FileIo
87 })?;
88 parse_payload_section_start::<BlobMeta>(&header).map_err(|e| report(path, e))
89}
90
91fn read_payload_section(path: &str) -> Result<Vec<u8>, CnResult> {
93 let data = read_file(path)?;
94 Ok(payload_section(&data).to_vec())
95}
96
97fn read_file(path: &str) -> Result<Vec<u8>, CnResult> {
98 fs::read(path).map_err(|e| {
99 tracing::error!("Failed to read {}: {}", path, e);
100 CnResult::FileIo
101 })
102}
103
104fn report(path: &str, e: BlobError) -> CnResult {
107 match e {
108 BlobError::TooShort => tracing::error!("{}: file too short", path),
109 BlobError::BadMagic => tracing::error!("{}: bad magic", path),
110 BlobError::ValidityMismatch(_) => tracing::error!(
111 "{}: world data was built by a different version of the engine",
112 path
113 ),
114 BlobError::TruncatedMeta => tracing::error!("{}: truncated metadata section", path),
115 BlobError::Decode => tracing::error!("{}: failed to deserialize metadata", path),
116 BlobError::TrailingMeta(n) => tracing::error!(
117 "{}: metadata left {} unread bytes; world data was built by a different version of the engine",
118 path,
119 n
120 ),
121 BlobError::Encode => tracing::error!("{}: failed to serialize metadata", path),
122 }
123 CnResult::FileIo
124}
125
126pub fn load_raw_at(primary: &Path) -> Result<(BlobMeta, BlobData), CnResult> {
132 *primary_override().lock().unwrap() = Some(primary.to_path_buf());
133 load_raw()
134}
135
136pub fn load_raw() -> Result<(BlobMeta, BlobData), CnResult> {
145 load_raw_from(blob_path)
146}
147
148fn load_raw_from(
151 blob_path: impl Fn(u32) -> Option<String>,
152) -> Result<(BlobMeta, BlobData), CnResult> {
153 let (meta, _payload_start) = read_cnb(&blob_path(0).ok_or(CnResult::NoStateRoot)?)?;
154
155 debug_assert_eq!(
158 meta.manifest,
159 WorldManifest::from_records(&meta.defs, &meta.resources),
160 "blob manifest does not match its record streams"
161 );
162
163 let blob0_payload = read_payload_section(&blob_path(0).ok_or(CnResult::NoStateRoot)?)?;
164 tracing::debug!("Loaded blob 0 payload ({} bytes)", blob0_payload.len());
165 let overflow_paths = (1..=meta.manifest.max_blob_index)
166 .map(|i| blob_path(i).ok_or(CnResult::NoStateRoot))
167 .collect::<Result<Vec<_>, _>>()?;
168
169 let blob_data = BlobData::from_blob_files(blob0_payload, overflow_paths);
170 Ok((meta, blob_data))
171}
172
173pub fn texture_resource_count() -> Result<usize, CnResult> {
178 let (meta, _) = read_cnb(&blob_path(0).ok_or(CnResult::NoStateRoot)?)?;
179 let tag = concinnity_core::ecs::ResourceKind::Texture as u8;
180 Ok(meta
181 .resources
182 .iter()
183 .filter(|r| r.resource_kind == tag)
184 .count())
185}
186
187pub fn load_defs() -> Result<Vec<BlobAssetDef>, CnResult> {
189 read_cnb(&blob_path(0).ok_or(CnResult::NoStateRoot)?).map(|(meta, _)| meta.defs)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use concinnity_core::blob::encode_cnb;
196
197 #[test]
198 fn an_anchored_primary_owns_blob_zero_and_its_siblings() {
199 let primary = Path::new("out").join("blobs").join("0");
203 assert_eq!(
204 resolve_blob_path(Some(&primary), None, 0).as_deref(),
205 Some(&*primary.to_string_lossy())
206 );
207 assert_eq!(
208 resolve_blob_path(Some(&primary), None, 2),
209 Some(
210 Path::new("out")
211 .join("blobs")
212 .join("2")
213 .to_string_lossy()
214 .into_owned()
215 )
216 );
217
218 assert_eq!(
220 resolve_blob_path(Some(Path::new("0")), None, 1).as_deref(),
221 Some("1")
222 );
223 }
224
225 #[test]
226 fn without_an_anchor_blobs_sit_under_the_data_dir() {
227 let data = Path::new("/proj").join("data");
228 assert_eq!(
229 resolve_blob_path(None, Some(&data), 3),
230 Some(data.join("3").to_string_lossy().into_owned())
231 );
232 }
233
234 #[test]
238 fn without_an_anchor_or_a_state_root_there_is_no_path() {
239 assert_eq!(resolve_blob_path(None, None, 0), None);
240 assert_eq!(resolve_blob_path(None, None, 3), None);
241 }
242
243 #[test]
244 fn format_failures_fold_onto_file_io() {
245 assert_eq!(report("x.cnb", BlobError::BadMagic), CnResult::FileIo);
246 assert_eq!(
247 report("x.cnb", BlobError::ValidityMismatch(99)),
248 CnResult::FileIo
249 );
250 }
251
252 #[test]
253 fn read_cnb_errors_on_a_missing_file() {
254 assert_eq!(
255 read_cnb("/nonexistent/cn/blob/path.cnb"),
256 Err(CnResult::FileIo)
257 );
258 }
259
260 #[test]
261 fn read_payload_section_returns_empty_for_a_short_file() {
262 let dir = tempfile::tempdir().unwrap();
263 let path = dir.path().join("short").to_string_lossy().into_owned();
264 std::fs::write(&path, vec![0u8; HEADER_SIZE - 1]).unwrap();
265 assert!(read_payload_section(&path).unwrap().is_empty());
266 }
267
268 #[test]
269 fn payload_section_start_skips_header_and_meta() {
270 let dir = tempfile::tempdir().unwrap();
271 let path = dir.path().join("0").to_string_lossy().into_owned();
272 let image = encode_cnb(SCHEMA_VERSION, &BlobMeta::default(), b"payloadbytes").unwrap();
273 std::fs::write(&path, &image).unwrap();
274
275 let start = payload_section_start(&path).expect("section start");
276 assert_eq!(&image[start as usize..], b"payloadbytes");
277 }
278
279 #[test]
280 fn payload_section_start_rejects_bad_magic() {
281 let dir = tempfile::tempdir().unwrap();
282 let path = dir.path().join("bad").to_string_lossy().into_owned();
283 std::fs::write(&path, vec![0u8; HEADER_SIZE]).unwrap();
284 assert_eq!(payload_section_start(&path), Err(CnResult::FileIo));
285 }
286
287 #[test]
288 fn load_raw_reads_blob0_eagerly_and_defers_overflow() {
289 use concinnity_core::ecs::{AssetKind, PayloadLocator};
290
291 let dir = tempfile::tempdir().unwrap();
292 let path_for = |idx: u32| {
293 Some(
294 dir.path()
295 .join(idx.to_string())
296 .to_string_lossy()
297 .into_owned(),
298 )
299 };
300
301 let defs = vec![BlobAssetDef {
305 name: None,
306 kind: AssetKind::Component,
307 discriminant: 1,
308 args_bytes: Vec::new(),
309 payload: Some(PayloadLocator {
310 blob_index: 1,
311 offset: 0,
312 len: 8,
313 }),
314 }];
315 let meta = BlobMeta {
316 manifest: WorldManifest::from_records(&defs, &[]),
317 defs,
318 resources: Vec::new(),
319 scene_groups: Vec::new(),
320 mesh_bounds: Vec::new(),
321 physics_budget: None,
322 };
323 std::fs::write(
324 path_for(0).unwrap(),
325 encode_cnb(SCHEMA_VERSION, &meta, b"primary").unwrap(),
326 )
327 .unwrap();
328 std::fs::write(
329 path_for(1).unwrap(),
330 encode_cnb(SCHEMA_VERSION, &BlobMeta::default(), b"overflow").unwrap(),
331 )
332 .unwrap();
333
334 let (meta, mut bd) = load_raw_from(path_for).expect("load");
335 assert_eq!(meta.defs.len(), 1);
336 assert!(meta.resources.is_empty());
337 assert_eq!(meta.manifest.component_counts, vec![(1, 1)]);
338 assert!(bd.disk_backed());
339 assert!(bd.is_loaded(0));
341 assert!(!bd.is_loaded(1));
342 let loc = meta.defs[0].payload.clone().unwrap();
343 assert_eq!(bd.read(&loc).expect("overflow read"), b"overflow");
344 assert!(bd.is_loaded(1));
345 }
346
347 #[test]
350 fn load_raw_without_a_layout_reports_no_state_root() {
351 assert_eq!(load_raw_from(|_| None).err(), Some(CnResult::NoStateRoot));
352 }
353}