use std::path::{Path, PathBuf};
use bincode::{Decode, Encode};
use crate::class_id::ClassId;
pub const SCHEMA_VERSION: u16 = 6;
pub const MAGIC: [u8; 8] = *b"UADBIN__";
pub const CACHE_MAGIC: [u8; 8] = *b"UADCACHE";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Encode, Decode)]
pub enum AssetType {
Native(u32),
Script(u32),
}
impl AssetType {
pub fn native(class_id: ClassId) -> Self {
Self::Native(class_id as u32)
}
}
#[derive(Debug, Clone, Encode, Decode)]
pub struct SubAsset {
pub file_id: i64,
pub class_id: u32,
pub name: Box<str>,
}
#[derive(Debug, Clone, Encode, Decode)]
pub struct AssetEntry {
pub guid: u128,
pub asset_type: AssetType,
pub name: Box<str>,
pub sub_assets: Vec<SubAsset>,
pub hint: Box<str>,
}
#[derive(Debug, Clone, Default, Encode, Decode)]
pub struct AssetDb {
pub schema_version: u16,
pub script_types: Vec<u128>,
pub entries: Vec<AssetEntry>,
}
impl AssetDb {
pub fn new() -> Self {
Self {
schema_version: SCHEMA_VERSION,
..Default::default()
}
}
pub fn find_by_guid(&self, guid: u128) -> Option<&AssetEntry> {
let idx = self.entries.binary_search_by_key(&guid, |e| e.guid).ok()?;
Some(&self.entries[idx])
}
pub fn script_guid(&self, idx: u32) -> u128 {
self.script_types[idx as usize]
}
pub fn intern_script(&mut self, guid: u128) -> u32 {
match self.script_types.binary_search(&guid) {
Ok(idx) => idx as u32,
Err(idx) => {
self.script_types.insert(idx, guid);
idx as u32
}
}
}
pub fn sort(&mut self) {
self.entries.sort_by_key(|e| e.guid);
for e in &mut self.entries {
e.sub_assets.sort_by_key(|s| s.file_id);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
pub enum CachedAssetType {
Native(u32),
Script(u128),
}
#[derive(Debug, Clone, Encode, Decode)]
pub struct CachedEntry {
pub hint: Box<str>,
pub meta_mtime_ns: u64,
pub asset_mtime_ns: u64,
pub guid: u128,
pub asset_type: CachedAssetType,
pub sub_assets: Vec<SubAsset>,
}
#[derive(Debug, Clone, Default, Encode, Decode)]
pub struct BakeCache {
pub schema_version: u16,
pub entries: Vec<CachedEntry>,
}
impl BakeCache {
pub fn new() -> Self {
Self {
schema_version: SCHEMA_VERSION,
..Default::default()
}
}
}
pub const DB_FILENAME: &str = "asset-db.bin";
pub const CACHE_FILENAME: &str = "asset-db.cache.bin";
pub fn db_path(dir: &Path) -> PathBuf {
dir.join(DB_FILENAME)
}
pub fn cache_path(dir: &Path) -> PathBuf {
dir.join(CACHE_FILENAME)
}
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("{op} {}: {source}", path.display())]
Io {
op: &'static str,
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{label} too short ({len} bytes)")]
MagicTooShort { label: &'static str, len: usize },
#[error("{label} magic mismatch")]
MagicMismatch { label: &'static str },
#[error("{label} schema {found} expected {expected}, re-bake required")]
SchemaMismatch {
label: &'static str,
found: u16,
expected: u16,
},
#[error("bincode decode: {0}")]
BincodeDecode(#[from] bincode::error::DecodeError),
#[error("bincode encode: {0}")]
BincodeEncode(#[from] bincode::error::EncodeError),
}
pub fn read(path: &Path) -> Result<AssetDb, StoreError> {
let bytes = std::fs::read(path).map_err(|source| StoreError::Io {
op: "read asset-db",
path: path.to_path_buf(),
source,
})?;
decode(&bytes)
}
pub fn decode(bytes: &[u8]) -> Result<AssetDb, StoreError> {
let body = check_magic(bytes, MAGIC, "asset-db")?;
let cfg = bincode::config::standard();
let (db, _): (AssetDb, _) = bincode::decode_from_slice(body, cfg)?;
if db.schema_version != SCHEMA_VERSION {
return Err(StoreError::SchemaMismatch {
label: "asset-db",
found: db.schema_version,
expected: SCHEMA_VERSION,
});
}
Ok(db)
}
pub fn write(path: &Path, db: &AssetDb) -> Result<(), StoreError> {
write_bytes(path, &encode(db)?)
}
pub fn encode(db: &AssetDb) -> Result<Vec<u8>, StoreError> {
encode_with_magic(db, MAGIC)
}
pub fn read_cache(path: &Path) -> Result<BakeCache, StoreError> {
let bytes = std::fs::read(path).map_err(|source| StoreError::Io {
op: "read cache",
path: path.to_path_buf(),
source,
})?;
decode_cache(&bytes)
}
pub fn decode_cache(bytes: &[u8]) -> Result<BakeCache, StoreError> {
let body = check_magic(bytes, CACHE_MAGIC, "asset-db.cache")?;
let cfg = bincode::config::standard();
let (cache, _): (BakeCache, _) = bincode::decode_from_slice(body, cfg)?;
if cache.schema_version != SCHEMA_VERSION {
return Err(StoreError::SchemaMismatch {
label: "asset-db.cache",
found: cache.schema_version,
expected: SCHEMA_VERSION,
});
}
Ok(cache)
}
pub fn write_cache(path: &Path, cache: &BakeCache) -> Result<(), StoreError> {
write_bytes(path, &encode_cache(cache)?)
}
pub fn encode_cache(cache: &BakeCache) -> Result<Vec<u8>, StoreError> {
encode_with_magic(cache, CACHE_MAGIC)
}
fn encode_with_magic<T: Encode>(value: &T, magic: [u8; 8]) -> Result<Vec<u8>, StoreError> {
let cfg = bincode::config::standard();
let body = bincode::encode_to_vec(value, cfg)?;
let mut out = Vec::with_capacity(magic.len() + body.len());
out.extend_from_slice(&magic);
out.extend_from_slice(&body);
Ok(out)
}
fn check_magic<'a>(
bytes: &'a [u8],
magic: [u8; 8],
label: &'static str,
) -> Result<&'a [u8], StoreError> {
if bytes.len() < magic.len() {
return Err(StoreError::MagicTooShort {
label,
len: bytes.len(),
});
}
let (head, body) = bytes.split_at(magic.len());
if head != magic {
return Err(StoreError::MagicMismatch { label });
}
Ok(body)
}
fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
op: "create dir",
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(path, bytes).map_err(|source| StoreError::Io {
op: "write",
path: path.to_path_buf(),
source,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::class_id::ClassId;
#[test]
fn roundtrip_empty() {
let db = AssetDb::new();
let bytes = encode(&db).unwrap();
let back = decode(&bytes).unwrap();
assert_eq!(back.schema_version, SCHEMA_VERSION);
assert!(back.entries.is_empty());
assert!(back.script_types.is_empty());
}
#[test]
fn roundtrip_with_entries() {
let mut db = AssetDb::new();
let script_guid = 0x1234_5678_9abc_def0_1122_3344_5566_7788_u128;
let idx = db.intern_script(script_guid);
db.entries.push(AssetEntry {
guid: 0xaabb_ccdd_u128,
asset_type: AssetType::native(ClassId::Prefab),
name: "Foo".into(),
sub_assets: vec![],
hint: "Assets/UI/Foo.prefab".into(),
});
db.entries.push(AssetEntry {
guid: 0x1111_2222_u128,
asset_type: AssetType::Script(idx),
name: "Bar".into(),
sub_assets: vec![SubAsset {
file_id: 21300000,
class_id: ClassId::Sprite as u32,
name: "Bar_sub".into(),
}],
hint: "Assets/Tween/Bar.asset".into(),
});
db.sort();
let bytes = encode(&db).unwrap();
let back = decode(&bytes).unwrap();
assert_eq!(back.script_types, vec![script_guid]);
assert_eq!(back.entries.len(), 2);
assert_eq!(back.entries[0].guid, 0x1111_2222_u128);
assert_eq!(&*back.find_by_guid(0xaabb_ccdd_u128).unwrap().name, "Foo");
assert!(back.find_by_guid(0xdead_beef_u128).is_none());
}
#[test]
fn intern_dedups() {
let mut db = AssetDb::new();
let g = 42u128;
let a = db.intern_script(g);
let b = db.intern_script(g);
assert_eq!(a, b);
assert_eq!(db.script_types.len(), 1);
}
#[test]
fn magic_mismatch_errors() {
let bad = b"NOTAPDB!extra".to_vec();
assert!(decode(&bad).is_err());
}
#[test]
fn cache_roundtrip() {
let mut c = BakeCache::new();
c.entries.push(CachedEntry {
hint: "UI/Foo.prefab".into(),
meta_mtime_ns: 1,
asset_mtime_ns: 2,
guid: 0xaa_u128,
asset_type: CachedAssetType::Native(1001),
sub_assets: vec![],
});
c.entries.push(CachedEntry {
hint: "Tween/Bar.asset".into(),
meta_mtime_ns: 3,
asset_mtime_ns: 4,
guid: 0xbb_u128,
asset_type: CachedAssetType::Script(0xcc_u128),
sub_assets: vec![],
});
let bytes = encode_cache(&c).unwrap();
let back = decode_cache(&bytes).unwrap();
assert_eq!(back.entries.len(), 2);
assert_eq!(
back.entries[1].asset_type,
CachedAssetType::Script(0xcc_u128)
);
}
#[test]
fn cache_magic_distinct_from_db() {
let c = BakeCache::new();
let cache_bytes = encode_cache(&c).unwrap();
assert!(decode(&cache_bytes).is_err());
let db = AssetDb::new();
let db_bytes = encode(&db).unwrap();
assert!(decode_cache(&db_bytes).is_err());
}
#[test]
fn schema_version_downgrade_hard_fails() {
let mut db = AssetDb::new();
db.schema_version = SCHEMA_VERSION.saturating_sub(1);
let bytes = encode(&db).unwrap();
let err = decode(&bytes).unwrap_err().to_string();
assert!(
err.contains("schema") && err.contains("re-bake"),
"expected schema-version error, got: {err}",
);
}
#[test]
fn schema_version_upgrade_hard_fails() {
let mut db = AssetDb::new();
db.schema_version = SCHEMA_VERSION + 1;
let bytes = encode(&db).unwrap();
let err = decode(&bytes).unwrap_err().to_string();
assert!(err.contains("schema"), "expected schema-version error, got: {err}");
}
#[test]
fn cache_schema_version_mismatch_hard_fails() {
let mut c = BakeCache::new();
c.schema_version = SCHEMA_VERSION.saturating_sub(1);
let bytes = encode_cache(&c).unwrap();
let err = decode_cache(&bytes).unwrap_err().to_string();
assert!(err.contains("schema"), "expected schema mismatch, got: {err}");
}
#[test]
fn subasset_class_id_round_trips() {
let mut db = AssetDb::new();
db.entries.push(AssetEntry {
guid: 0xa0_u128,
asset_type: AssetType::native(ClassId::AnimatorController),
name: "Foo".into(),
sub_assets: vec![
SubAsset {
file_id: 9100000,
class_id: ClassId::AnimatorController as u32,
name: "Foo_self".into(),
},
SubAsset {
file_id: -123456789,
class_id: 1102, name: "Idle".into(),
},
],
hint: "Assets/Foo.controller".into(),
});
db.sort();
let bytes = encode(&db).unwrap();
let back = decode(&bytes).unwrap();
let entry = back.find_by_guid(0xa0_u128).unwrap();
assert_eq!(entry.sub_assets.len(), 2);
let self_sub = entry
.sub_assets
.iter()
.find(|s| &*s.name == "Foo_self")
.unwrap();
assert_eq!(self_sub.class_id, ClassId::AnimatorController as u32);
let idle = entry.sub_assets.iter().find(|s| &*s.name == "Idle").unwrap();
assert_eq!(idle.class_id, 1102);
}
}