concinnity-host 0.19.2

Host services for Concinnity: the on-disk state tree and the thread-scoped services
Documentation
//! Runtime blob access: the state root's `data/` path layout, the payload
//! residency store, and all blob file I/O. The concinnity-blob crate owns the
//! format contract (schema, header, version, bytes <-> metadata) and is
//! deliberately I/O-free, so every read below is `fs` here plus a pure parse
//! there. Blob data is read-only at runtime; concinnity-cook writes what
//! `concinnity_core::blob::encode_cnb` returns.
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;

// The primary blob this process reads, named by whatever anchored it. Process
// state because payloads stream off disk long after startup: a locator resolved
// mid-frame has no caller to carry the layout down from.
fn anchored_primary() -> &'static Mutex<Option<PathBuf>> {
    static PRIMARY: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
    PRIMARY.get_or_init(|| Mutex::new(None))
}

// Anchor the process's blob layout on `primary`: that file is blob 0 and its
// siblings named by index are the overflow payload blobs. Reached only through
// `load_raw_at`, so a process addresses the blobs it actually opened.
fn anchor(primary: &Path) {
    *anchored_primary().lock().unwrap() = Some(primary.to_path_buf());
}

// Blob 0 is the primary blob; every other index names an overflow sibling.
const PRIMARY_INDEX: u32 = 0;

/// The primary blob inside `data_dir`: blob 0, holding the metadata block plus
/// the first payload section. What a host opens when it reads a state tree's
/// `data/`, and what a build writes there.
pub fn primary_in(data_dir: &Path) -> PathBuf {
    data_dir.join(PRIMARY_INDEX.to_string())
}

/// Format a blob file path for a given index. Blob 0 is the primary blob
/// [`load_raw_at`] opened (the metadata block plus the first payload section);
/// higher indices are overflow payload blobs, which are always its siblings.
/// The format crate is path-agnostic; this layout knowledge stays here.
///
/// `None` before any load, so there is no layout to resolve against.
pub fn blob_path(index: u32) -> Option<String> {
    let primary = anchored_primary().lock().unwrap().clone();
    resolve_blob_path(primary.as_deref(), index)
}

// Pure resolution split out so the sibling naming is unit-testable without the
// process-global anchor.
fn resolve_blob_path(primary: Option<&Path>, index: u32) -> Option<String> {
    let primary = primary?;
    let path = if index == PRIMARY_INDEX {
        primary.to_path_buf()
    } else {
        primary
            .parent()
            .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
            .join(index.to_string())
    };
    Some(path.to_string_lossy().into_owned())
}

/// Read and deserialize a blob's metadata section (component defs + resource
/// records). Returns (meta, payload_start_offset).
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))
}

/// Byte offset within a blob file at which its payload section begins. Reads
/// only the header; the disk-backed streaming source uses it to turn a
/// `PayloadLocator` offset into an absolute file offset.
/// Used only by the Metal-driven disk-backed streaming source for now
/// (Vulkan/DirectX streaming catch-up is a follow-up).
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))
}

// Read just the payload section of a blob file into memory.
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
    })
}

// Log a format failure against the file it came from. The format crate has no
// path to name, so the diagnostic belongs here.
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
}

/// Load the blob file at `primary` and the payload store around it, anchoring
/// the process's blob layout on it, so a world written to `data/0` reads
/// `data/1`, `data/2`, ... beside it. The anchor outlives the call because
/// payloads stream off disk long after startup: a locator resolved mid-frame
/// has no caller to carry the layout down from.
///
/// Only blob 0's payload section is read here; overflow blobs (named by the
/// manifest's `max_blob_index`) start unloaded and `BlobData::read()` pulls
/// each from disk the first time a locator needs it. Defs are not resolved into
/// runtime `Asset`s: that resolution depends on the client runtime registry, so
/// it lives in the client `blob::load` shim.
pub fn load_raw_at(primary: &Path) -> Result<(BlobMeta, BlobData), CnResult> {
    anchor(primary);
    load_raw_from(blob_path)
}

// `load_raw` against an injected layout, so the eager/deferred split can be
// exercised without the process-global data-dir anchor.
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)?)?;

    // Cook derives the manifest from the very streams it summarizes, so a
    // mismatch means a corrupt or hand-edited blob.
    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))
}

/// Number of Texture resource records in the primary blob's metadata, read
/// without loading any payload. This is the compiled world's texture-table
/// length; `cn export` uses it to precompile the built-in shaders whose bindless
/// texture pool is sized per world.
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())
}

/// Load defs without resolving (for callers that apply overlays first)
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() {
        // Blob 0 is the file named verbatim (whatever it is called); every
        // overflow blob is its sibling named by index. Built through `join` so
        // the separator is the platform's.
        let primary = Path::new("out").join("blobs").join("0");
        assert_eq!(
            resolve_blob_path(Some(&primary), 0).as_deref(),
            Some(&*primary.to_string_lossy())
        );
        assert_eq!(
            resolve_blob_path(Some(&primary), 2),
            Some(
                Path::new("out")
                    .join("blobs")
                    .join("2")
                    .to_string_lossy()
                    .into_owned()
            )
        );

        // A bare file name hangs its siblings off the working directory.
        assert_eq!(
            resolve_blob_path(Some(Path::new("0")), 1).as_deref(),
            Some("1")
        );

        // A state tree's `data/` is just a primary named `<tree>/data/0`, so
        // the overflow blobs land beside it the same way.
        let tree = crate::store::paths::StateTree::at(Path::new("/proj"));
        let data = tree.data_dir().join("0");
        assert_eq!(
            resolve_blob_path(Some(&data), 3),
            Some(tree.data_dir().join("3").to_string_lossy().into_owned())
        );
    }

    // With nothing anchored there is no layout to resolve against, which is
    // what turns a blob read into `NoStateRoot` rather than a read of some path
    // relative to the working directory.
    #[test]
    fn without_an_anchor_there_is_no_path() {
        assert_eq!(resolve_blob_path(None, 0), None);
        assert_eq!(resolve_blob_path(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(),
            )
        };

        // Blob 0: one def whose payload lives in overflow blob 1. The manifest
        // is derived exactly as cook derives it; `load_raw` trusts its
        // `max_blob_index` to name the overflow file.
        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());
        // Blob 0 resident, blob 1 deferred until its first read.
        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));
    }

    // A layout that resolves to nothing is the uninstalled-state-root case, and
    // it has to name itself rather than folding onto a file-not-found.
    #[test]
    fn load_raw_without_a_layout_reports_no_state_root() {
        assert_eq!(load_raw_from(|_| None).err(), Some(CnResult::NoStateRoot));
    }
}