use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub use concinnity_core::blob::{BLOB_MAGIC, HEADER_SIZE, WorldManifest};
use concinnity_core::blob::{BlobError, parse_cnb, parse_payload_section_start, payload_section};
use concinnity_core::result::CnResult;
mod data;
pub use concinnity_core::SCHEMA_VERSION;
pub use concinnity_core::ecs::{BlobAssetDef, BlobMeta, ResourceRecord};
pub use data::BlobData;
fn primary_override() -> &'static Mutex<Option<PathBuf>> {
static PRIMARY: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
PRIMARY.get_or_init(|| Mutex::new(None))
}
pub fn blob_path(index: u32) -> Option<String> {
let primary = primary_override().lock().unwrap().clone();
resolve_blob_path(
primary.as_deref(),
super::paths::data_dir().as_deref(),
index,
)
}
fn resolve_blob_path(
primary: Option<&Path>,
data_dir: Option<&Path>,
index: u32,
) -> Option<String> {
let path = match primary {
Some(p) if index == 0 => p.to_path_buf(),
Some(p) => p
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
.join(index.to_string()),
None => data_dir?.join(index.to_string()),
};
Some(path.to_string_lossy().into_owned())
}
pub fn read_cnb(path: &str) -> Result<(BlobMeta, usize), CnResult> {
let data = read_file(path)?;
parse_cnb::<BlobMeta>(SCHEMA_VERSION, &data).map_err(|e| report(path, e))
}
pub fn payload_section_start(path: &str) -> Result<u64, CnResult> {
let mut file = fs::File::open(path).map_err(|e| {
tracing::error!("Failed to open {}: {}", path, e);
CnResult::FileIo
})?;
let mut header = [0u8; HEADER_SIZE];
file.read_exact(&mut header).map_err(|e| {
tracing::error!("Failed to read header of {}: {}", path, e);
CnResult::FileIo
})?;
parse_payload_section_start::<BlobMeta>(&header).map_err(|e| report(path, e))
}
fn read_payload_section(path: &str) -> Result<Vec<u8>, CnResult> {
let data = read_file(path)?;
Ok(payload_section(&data).to_vec())
}
fn read_file(path: &str) -> Result<Vec<u8>, CnResult> {
fs::read(path).map_err(|e| {
tracing::error!("Failed to read {}: {}", path, e);
CnResult::FileIo
})
}
fn report(path: &str, e: BlobError) -> CnResult {
match e {
BlobError::TooShort => tracing::error!("{}: file too short", path),
BlobError::BadMagic => tracing::error!("{}: bad magic", path),
BlobError::ValidityMismatch(_) => tracing::error!(
"{}: world data was built by a different version of the engine",
path
),
BlobError::TruncatedMeta => tracing::error!("{}: truncated metadata section", path),
BlobError::Decode => tracing::error!("{}: failed to deserialize metadata", path),
BlobError::TrailingMeta(n) => tracing::error!(
"{}: metadata left {} unread bytes; world data was built by a different version of the engine",
path,
n
),
BlobError::Encode => tracing::error!("{}: failed to serialize metadata", path),
}
CnResult::FileIo
}
pub fn load_raw_at(primary: &Path) -> Result<(BlobMeta, BlobData), CnResult> {
*primary_override().lock().unwrap() = Some(primary.to_path_buf());
load_raw()
}
pub fn load_raw() -> Result<(BlobMeta, BlobData), CnResult> {
load_raw_from(blob_path)
}
fn load_raw_from(
blob_path: impl Fn(u32) -> Option<String>,
) -> Result<(BlobMeta, BlobData), CnResult> {
let (meta, _payload_start) = read_cnb(&blob_path(0).ok_or(CnResult::NoStateRoot)?)?;
debug_assert_eq!(
meta.manifest,
WorldManifest::from_records(&meta.defs, &meta.resources),
"blob manifest does not match its record streams"
);
let blob0_payload = read_payload_section(&blob_path(0).ok_or(CnResult::NoStateRoot)?)?;
tracing::debug!("Loaded blob 0 payload ({} bytes)", blob0_payload.len());
let overflow_paths = (1..=meta.manifest.max_blob_index)
.map(|i| blob_path(i).ok_or(CnResult::NoStateRoot))
.collect::<Result<Vec<_>, _>>()?;
let blob_data = BlobData::from_blob_files(blob0_payload, overflow_paths);
Ok((meta, blob_data))
}
pub fn texture_resource_count() -> Result<usize, CnResult> {
let (meta, _) = read_cnb(&blob_path(0).ok_or(CnResult::NoStateRoot)?)?;
let tag = concinnity_core::ecs::ResourceKind::Texture as u8;
Ok(meta
.resources
.iter()
.filter(|r| r.resource_kind == tag)
.count())
}
pub fn load_defs() -> Result<Vec<BlobAssetDef>, CnResult> {
read_cnb(&blob_path(0).ok_or(CnResult::NoStateRoot)?).map(|(meta, _)| meta.defs)
}
#[cfg(test)]
mod tests {
use super::*;
use concinnity_core::blob::encode_cnb;
#[test]
fn an_anchored_primary_owns_blob_zero_and_its_siblings() {
let primary = Path::new("out").join("blobs").join("0");
assert_eq!(
resolve_blob_path(Some(&primary), None, 0).as_deref(),
Some(&*primary.to_string_lossy())
);
assert_eq!(
resolve_blob_path(Some(&primary), None, 2),
Some(
Path::new("out")
.join("blobs")
.join("2")
.to_string_lossy()
.into_owned()
)
);
assert_eq!(
resolve_blob_path(Some(Path::new("0")), None, 1).as_deref(),
Some("1")
);
}
#[test]
fn without_an_anchor_blobs_sit_under_the_data_dir() {
let data = Path::new("/proj").join("data");
assert_eq!(
resolve_blob_path(None, Some(&data), 3),
Some(data.join("3").to_string_lossy().into_owned())
);
}
#[test]
fn without_an_anchor_or_a_state_root_there_is_no_path() {
assert_eq!(resolve_blob_path(None, None, 0), None);
assert_eq!(resolve_blob_path(None, None, 3), None);
}
#[test]
fn format_failures_fold_onto_file_io() {
assert_eq!(report("x.cnb", BlobError::BadMagic), CnResult::FileIo);
assert_eq!(
report("x.cnb", BlobError::ValidityMismatch(99)),
CnResult::FileIo
);
}
#[test]
fn read_cnb_errors_on_a_missing_file() {
assert_eq!(
read_cnb("/nonexistent/cn/blob/path.cnb"),
Err(CnResult::FileIo)
);
}
#[test]
fn read_payload_section_returns_empty_for_a_short_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("short").to_string_lossy().into_owned();
std::fs::write(&path, vec![0u8; HEADER_SIZE - 1]).unwrap();
assert!(read_payload_section(&path).unwrap().is_empty());
}
#[test]
fn payload_section_start_skips_header_and_meta() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("0").to_string_lossy().into_owned();
let image = encode_cnb(SCHEMA_VERSION, &BlobMeta::default(), b"payloadbytes").unwrap();
std::fs::write(&path, &image).unwrap();
let start = payload_section_start(&path).expect("section start");
assert_eq!(&image[start as usize..], b"payloadbytes");
}
#[test]
fn payload_section_start_rejects_bad_magic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad").to_string_lossy().into_owned();
std::fs::write(&path, vec![0u8; HEADER_SIZE]).unwrap();
assert_eq!(payload_section_start(&path), Err(CnResult::FileIo));
}
#[test]
fn load_raw_reads_blob0_eagerly_and_defers_overflow() {
use concinnity_core::ecs::{AssetKind, PayloadLocator};
let dir = tempfile::tempdir().unwrap();
let path_for = |idx: u32| {
Some(
dir.path()
.join(idx.to_string())
.to_string_lossy()
.into_owned(),
)
};
let defs = vec![BlobAssetDef {
name: None,
kind: AssetKind::Component,
discriminant: 1,
args_bytes: Vec::new(),
payload: Some(PayloadLocator {
blob_index: 1,
offset: 0,
len: 8,
}),
}];
let meta = BlobMeta {
manifest: WorldManifest::from_records(&defs, &[]),
defs,
resources: Vec::new(),
scene_groups: Vec::new(),
mesh_bounds: Vec::new(),
physics_budget: None,
};
std::fs::write(
path_for(0).unwrap(),
encode_cnb(SCHEMA_VERSION, &meta, b"primary").unwrap(),
)
.unwrap();
std::fs::write(
path_for(1).unwrap(),
encode_cnb(SCHEMA_VERSION, &BlobMeta::default(), b"overflow").unwrap(),
)
.unwrap();
let (meta, mut bd) = load_raw_from(path_for).expect("load");
assert_eq!(meta.defs.len(), 1);
assert!(meta.resources.is_empty());
assert_eq!(meta.manifest.component_counts, vec![(1, 1)]);
assert!(bd.disk_backed());
assert!(bd.is_loaded(0));
assert!(!bd.is_loaded(1));
let loc = meta.defs[0].payload.clone().unwrap();
assert_eq!(bd.read(&loc).expect("overflow read"), b"overflow");
assert!(bd.is_loaded(1));
}
#[test]
fn load_raw_without_a_layout_reports_no_state_root() {
assert_eq!(load_raw_from(|_| None).err(), Some(CnResult::NoStateRoot));
}
}