use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use std::time::{Instant, SystemTime};
use ahash::{AHashMap, AHashSet};
use anyhow::{Context, Result};
use crate::asset;
use crate::class_id::{ClassId, class_from_ext};
use crate::meta::{self, SPRITE_MODE_SINGLE, TEXTURE_TYPE_SPRITE};
use crate::store::{
self, AssetDb, AssetEntry, AssetType, BakeCache, CachedAssetType, CachedEntry, StoreError,
SubAsset, CACHE_FILENAME, DB_FILENAME,
};
use crate::walk::{walk_meta_files, WalkError};
#[derive(Debug, thiserror::Error)]
pub enum BakeError {
#[error("{0}")]
Store(#[from] StoreError),
#[error("{0}")]
Walk(#[from] WalkError),
#[error("{0}")]
Other(#[from] anyhow::Error),
}
pub type NameSanitizer = Box<dyn Fn(&str) -> Option<String> + Send + Sync + 'static>;
pub type WarnSink = Box<dyn Fn(&str) + Send + Sync + 'static>;
pub type ProgressSink = Box<dyn Fn(&str) + Send + Sync + 'static>;
type NameSanitizerRef<'a> = &'a (dyn Fn(&str) -> Option<String> + Send + Sync);
type WarnSinkRef<'a> = &'a (dyn Fn(&str) + Send + Sync);
const EMBEDDED_CONTAINER_EXTS: &[&str] = &["prefab", "controller", "anim", "mixer", "playable"];
fn is_embedded_container(hint: &str) -> bool {
Path::new(hint)
.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| EMBEDDED_CONTAINER_EXTS.contains(&ext))
}
fn is_filterable_subdoc_for_ext(class_id: u32, ext: &str) -> bool {
let cls = ClassId::from_raw(class_id);
let is_go_tree = matches!(
cls,
Some(ClassId::GameObject | ClassId::Transform | ClassId::RectTransform)
);
let is_component = matches!(cls, Some(ClassId::MonoBehaviour));
is_go_tree || (is_component && ext == "prefab")
}
fn mtime_ns(t: SystemTime) -> u64 {
t.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64)
}
#[derive(Clone)]
struct RawEntry {
guid: u128,
asset_type_raw: AssetTypeRaw,
hint: String,
name: String,
meta_mtime_ns: u64,
asset_mtime_ns: u64,
sub_assets: Vec<SubAsset>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum AssetTypeRaw {
Native(u32),
Script(u128),
}
struct ThreadLocal {
entries: Vec<RawEntry>,
errors: Vec<String>,
raw_tx: mpsc::Sender<Vec<RawEntry>>,
err_tx: mpsc::Sender<Vec<String>>,
}
impl Drop for ThreadLocal {
fn drop(&mut self) {
let entries = std::mem::take(&mut self.entries);
let errors = std::mem::take(&mut self.errors);
let _ = self.raw_tx.send(entries);
let _ = self.err_tx.send(errors);
}
}
type CacheMap = AHashMap<String, RawEntry>;
fn run_with_panic_safety<T, F>(label: &str, task_name: &str, f: F) -> Result<Option<T>, String>
where
F: FnOnce() -> Result<Option<T>>,
{
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(Ok(opt)) => Ok(opt),
Ok(Err(e)) => Err(format!("{label}: {e}")),
Err(panic) => {
let msg = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "<non-string panic payload>".to_string());
Err(format!("{label}: panic in {task_name}: {msg}"))
}
}
}
fn build_cache(cache: BakeCache) -> CacheMap {
let mut out = AHashMap::with_capacity(cache.entries.len());
for e in cache.entries {
let asset_type_raw = match e.asset_type {
CachedAssetType::Native(n) => AssetTypeRaw::Native(n),
CachedAssetType::Script(g) => AssetTypeRaw::Script(g),
};
let hint = String::from(e.hint);
let raw = RawEntry {
guid: e.guid,
asset_type_raw,
hint: hint.clone(),
name: String::new(), meta_mtime_ns: e.meta_mtime_ns,
asset_mtime_ns: e.asset_mtime_ns,
sub_assets: e.sub_assets,
};
out.insert(hint, raw);
}
out
}
fn build_bake_cache(raw: &[RawEntry]) -> BakeCache {
let mut entries: Vec<CachedEntry> = raw
.iter()
.map(|r| CachedEntry {
hint: r.hint.clone().into_boxed_str(),
meta_mtime_ns: r.meta_mtime_ns,
asset_mtime_ns: r.asset_mtime_ns,
guid: r.guid,
asset_type: match r.asset_type_raw {
AssetTypeRaw::Native(n) => CachedAssetType::Native(n),
AssetTypeRaw::Script(g) => CachedAssetType::Script(g),
},
sub_assets: r.sub_assets.clone(),
})
.collect();
entries.sort_by(|a, b| a.hint.cmp(&b.hint));
BakeCache {
schema_version: store::SCHEMA_VERSION,
entries,
}
}
pub struct BakeOptions {
pub project_root: PathBuf,
pub out_dir: PathBuf,
pub name_sanitizer: Option<NameSanitizer>,
pub on_warn: Option<WarnSink>,
pub on_progress: Option<ProgressSink>,
pub verbose_timing: bool,
pub verbose_collisions: bool,
}
pub fn bake(opts: &BakeOptions) -> Result<(), BakeError> {
bake_inner(opts).map_err(|e| {
match e.downcast::<StoreError>() {
Ok(s) => return BakeError::Store(s),
Err(e) => match e.downcast::<WalkError>() {
Ok(w) => return BakeError::Walk(w),
Err(e) => BakeError::Other(e),
},
}
})
}
fn bake_inner(opts: &BakeOptions) -> Result<()> {
let project_root = &opts.project_root;
std::fs::create_dir_all(&opts.out_dir)
.with_context(|| format!("create out-dir: {}", opts.out_dir.display()))?;
let db_file = opts.out_dir.join(DB_FILENAME);
let cache_file = opts.out_dir.join(CACHE_FILENAME);
let t_start = Instant::now();
let cache: CacheMap = match store::read_cache(&cache_file) {
Ok(c) => build_cache(c),
Err(_) => AHashMap::new(),
};
let cache_size = cache.len();
let t_cache = t_start.elapsed();
let (raw_tx, raw_rx) = mpsc::channel::<Vec<RawEntry>>();
let (err_tx, err_rx) = mpsc::channel::<Vec<String>>();
let cache_arc = Arc::new(cache);
let cache_hits = Arc::new(AtomicUsize::new(0));
let walked = Arc::new(AtomicUsize::new(0));
let project_root_arc: Arc<PathBuf> = Arc::new(project_root.clone());
walk_meta_files(project_root, || {
let raw_tx = raw_tx.clone();
let err_tx = err_tx.clone();
let cache = Arc::clone(&cache_arc);
let cache_hits = Arc::clone(&cache_hits);
let walked = Arc::clone(&walked);
let project_root = Arc::clone(&project_root_arc);
let mut local = ThreadLocal {
entries: Vec::with_capacity(2048),
errors: Vec::new(),
raw_tx,
err_tx,
};
move |meta_path: &Path| {
walked.fetch_add(1, Ordering::Relaxed);
let label = meta_path.display().to_string();
match run_with_panic_safety(&label, "process_one", || {
process_one(meta_path, &project_root, &cache, &cache_hits)
}) {
Ok(Some(r)) => local.entries.push(r),
Ok(None) => {}
Err(msg) => local.errors.push(msg),
}
}
})?;
drop(raw_tx);
drop(err_tx);
let t_walk = t_start.elapsed();
let mut errors: Vec<String> = Vec::new();
for v in err_rx.iter() {
errors.extend(v);
}
if let Some(sink) = opts.on_warn.as_ref() {
for e in &errors {
sink(&format!("warning: {e}"));
}
}
let mut raw: Vec<RawEntry> = Vec::with_capacity(cache_size + 256);
for v in raw_rx.iter() {
raw.extend(v);
}
let bake_cache = build_bake_cache(&raw);
let db = build_db(
raw,
opts.name_sanitizer.as_deref(),
opts.on_warn.as_deref(),
opts.verbose_collisions,
)?;
let t_build = t_start.elapsed();
let hit_n = cache_hits.load(Ordering::Relaxed);
let no_op =
hit_n == cache_size && hit_n == db.entries.len() && db_file.exists() && cache_file.exists();
if !no_op {
store::write(&db_file, &db)
.with_context(|| format!("write asset-db: {}", db_file.display()))?;
store::write_cache(&cache_file, &bake_cache)
.with_context(|| format!("write cache: {}", cache_file.display()))?;
}
let t_write = t_start.elapsed();
if let Some(sink) = opts.on_progress.as_ref() {
sink(&format!(
"baked {} entries → {}",
db.entries.len(),
db_file.display()
));
if opts.verbose_timing {
let walked_n = walked.load(Ordering::Relaxed);
let parsed_n = db.entries.len() - hit_n;
let write_phase = if no_op { "skipped" } else { "wrote" };
sink(&format!(
" walked={walked_n} hit={hit_n} parsed={parsed_n} | cache={:?} walk={:?} build={:?} write={:?} ({write_phase}) total={:?}",
t_cache,
t_walk - t_cache,
t_build - t_walk,
t_write - t_build,
t_write,
));
}
}
Ok(())
}
fn process_one(
meta_path: &Path,
project_root: &Path,
cache: &CacheMap,
cache_hits: &AtomicUsize,
) -> Result<Option<RawEntry>> {
let companion =
strip_meta_suffix(meta_path).ok_or_else(|| anyhow::anyhow!("not a .meta path"))?;
let hint = rel_hint(project_root, &companion)?;
let meta_md =
std::fs::metadata(meta_path).with_context(|| format!("stat: {}", meta_path.display()))?;
let meta_mtime_ns = mtime_ns(meta_md.modified().unwrap_or(SystemTime::UNIX_EPOCH));
if let Some(cached) = cache.get(&hint)
&& cached.meta_mtime_ns == meta_mtime_ns
{
cache_hits.fetch_add(1, Ordering::Relaxed);
return Ok(Some(cached.clone()));
}
let Ok(companion_md) = std::fs::metadata(&companion) else {
return Ok(None);
};
if companion_md.is_dir() {
return Ok(None);
}
let asset_mtime_ns = mtime_ns(companion_md.modified().unwrap_or(SystemTime::UNIX_EPOCH));
process_one_uncached(meta_path, &companion, &hint, meta_mtime_ns, asset_mtime_ns)
}
fn process_one_uncached(
meta_path: &Path,
companion: &Path,
hint: &str,
meta_mtime_ns: u64,
asset_mtime_ns: u64,
) -> Result<Option<RawEntry>> {
let meta_text = std::fs::read_to_string(meta_path)
.with_context(|| format!("read .meta: {}", meta_path.display()))?;
let meta_info = meta::parse(&meta_text)?;
let ext = companion.extension().and_then(|s| s.to_str()).unwrap_or("");
let from_ext = class_from_ext(ext);
let mut sub_assets: Vec<SubAsset> = Vec::new();
let mut top_class_id: Option<u32> = None;
let mut script_guid: Option<u128> = None;
let parse_mode: Option<asset::ParseMode> = match ext {
"asset" | "spriteatlas" | "spriteatlasv2" | "prefab" | "controller" | "anim"
| "mixer" | "playable" => Some(asset::ParseMode::WithSubAssets),
"mat" | "mask" | "unity" => Some(asset::ParseMode::TopOnly),
_ => None,
};
if let Some(mode) = parse_mode {
let asset_text = read_asset_for_mode(companion, mode)?;
let info = asset::parse(&asset_text, mode)?;
top_class_id = info.top_class_id;
script_guid = info.script_guid;
for s in info.sub_assets {
if s.name.is_empty() {
continue;
}
if is_filterable_subdoc_for_ext(s.class_id, ext) {
continue;
}
sub_assets.push(SubAsset {
file_id: s.file_id,
class_id: s.class_id,
name: s.name.into_boxed_str(),
});
}
}
let asset_type_raw = if let Some(g) = script_guid {
AssetTypeRaw::Script(g)
} else if let Some(cls) = from_ext {
AssetTypeRaw::Native(cls as u32)
} else if let Some(cls) = top_class_id.and_then(ClassId::from_raw) {
AssetTypeRaw::Native(cls as u32)
} else if let Some(cls) = top_class_id {
AssetTypeRaw::Native(cls)
} else {
return Ok(None);
};
let name = filename_stem(companion);
let implicit_sprite = synthesize_implicit_sprite(&meta_info, &name);
for (fid, name) in meta_info.sprite_sheet {
sub_assets.push(SubAsset {
file_id: fid,
class_id: ClassId::Sprite as u32,
name: name.into_boxed_str(),
});
}
if let Some(sub) = implicit_sprite {
sub_assets.push(sub);
}
Ok(Some(RawEntry {
guid: meta_info.guid,
asset_type_raw,
hint: hint.to_string(),
name,
meta_mtime_ns,
asset_mtime_ns,
sub_assets,
}))
}
fn synthesize_implicit_sprite(meta: &meta::MetaInfo, stem: &str) -> Option<SubAsset> {
if meta.sprite_sheet.is_empty()
&& meta.texture_type == Some(TEXTURE_TYPE_SPRITE)
&& meta.sprite_mode == Some(SPRITE_MODE_SINGLE)
{
Some(SubAsset {
file_id: ClassId::Sprite.canonical_subobject_fid(),
class_id: ClassId::Sprite as u32,
name: stem.to_string().into_boxed_str(),
})
} else {
None
}
}
fn warn_sanitized(on_warn: Option<WarnSinkRef<'_>>, kind: &str, hint: &str, old: &str, new: &str) {
if let Some(sink) = on_warn {
sink(&format!(
"warning: {kind} {hint} name `{old}` contains ref-reserved char; renamed to `{new}`",
));
}
}
fn build_db(
mut raw: Vec<RawEntry>,
sanitizer: Option<NameSanitizerRef<'_>>,
on_warn: Option<WarnSinkRef<'_>>,
verbose_collisions: bool,
) -> Result<AssetDb> {
raw.sort_by(|a, b| a.hint.cmp(&b.hint));
for r in raw.iter_mut() {
r.name = filename_stem_from_hint(&r.hint);
if let Some(san) = sanitizer
&& let Some(clean) = san(&r.name)
{
warn_sanitized(on_warn, "asset", &r.hint, &r.name, &clean);
r.name = clean;
}
if let Some(san) = sanitizer {
for sub in r.sub_assets.iter_mut() {
if let Some(clean) = san(&sub.name) {
warn_sanitized(on_warn, "sub-asset of", &r.hint, &sub.name, &clean);
sub.name = clean.into_boxed_str();
}
}
}
}
let mut owners: AHashMap<(String, AssetTypeRaw), AHashSet<u128>> =
AHashMap::with_capacity(raw.len());
for r in &raw {
let key = (r.name.clone(), r.asset_type_raw);
owners.entry(key).or_default().insert(r.guid);
if is_embedded_container(&r.hint) {
continue;
}
for sub in &r.sub_assets {
let key = (
sub.name.to_string(),
AssetTypeRaw::Native(sub.class_id),
);
owners.entry(key).or_default().insert(r.guid);
}
}
let contested = |name: &str, t: AssetTypeRaw| {
owners
.get(&(name.to_string(), t))
.is_some_and(|s| s.len() > 1)
};
let mut taken: AHashMap<(String, AssetTypeRaw), u128> = AHashMap::with_capacity(raw.len());
for r in raw.iter_mut() {
let top_type = r.asset_type_raw;
if contested(&r.name, top_type) {
let new_name = disambiguate(&r.name, &r.hint, r.guid, top_type, &taken)?;
if verbose_collisions && let Some(sink) = on_warn {
sink(&format!(
"warning: name collision on `{}` (guid {:032x}); renamed to `{}`",
r.name, r.guid, new_name,
));
}
r.name = new_name;
}
match taken.get(&(r.name.clone(), top_type)) {
Some(&prev) if prev != r.guid => anyhow::bail!(
"asset-db: name `{}` claimed by both guid {:032x} and {prev:032x} \
after dedup — `disambiguate` produced a non-unique alias",
r.name,
r.guid,
),
_ => {
taken.insert((r.name.clone(), top_type), r.guid);
}
}
if is_embedded_container(&r.hint) {
continue;
}
for sub in r.sub_assets.iter_mut() {
let sub_type = AssetTypeRaw::Native(sub.class_id);
if contested(&sub.name, sub_type) {
let original = sub.name.to_string();
let new_name = disambiguate(&original, &r.hint, r.guid, sub_type, &taken)?;
if verbose_collisions && let Some(sink) = on_warn {
sink(&format!(
"warning: sub-asset name collision on `{}` (parent guid {:032x}); renamed to `{}`",
original, r.guid, new_name,
));
}
sub.name = new_name.into_boxed_str();
}
let key = (sub.name.to_string(), sub_type);
if !taken.contains_key(&key) {
taken.insert(key, r.guid);
}
}
}
let mut db = AssetDb::new();
let entries: Vec<AssetEntry> = raw
.into_iter()
.map(|r| {
let asset_type = match r.asset_type_raw {
AssetTypeRaw::Native(n) => AssetType::Native(n),
AssetTypeRaw::Script(g) => AssetType::Script(db.intern_script(g)),
};
AssetEntry {
guid: r.guid,
asset_type,
name: r.name.into_boxed_str(),
sub_assets: r.sub_assets,
hint: r.hint.into_boxed_str(),
}
})
.collect();
db.entries = entries;
db.sort();
check_no_full_duplicates(&db)?;
Ok(db)
}
fn check_no_full_duplicates(db: &AssetDb) -> Result<()> {
for w in db.entries.windows(2) {
if w[0].guid == w[1].guid {
anyhow::bail!(
"duplicate top-level GUID: {:032x} between names `{}` and `{}` — likely two .meta files share a GUID",
w[0].guid,
w[0].name,
w[1].name,
);
}
}
let mut seen: AHashSet<(i64, &str)> = AHashSet::new();
for e in &db.entries {
seen.clear();
for s in &e.sub_assets {
if !seen.insert((s.file_id, &*s.name)) {
anyhow::bail!(
"duplicate sub-asset record: name={} guid={:032x} fileID={} type={:?}",
s.name,
e.guid,
s.file_id,
e.asset_type,
);
}
}
}
Ok(())
}
fn read_asset_for_mode(path: &Path, mode: asset::ParseMode) -> Result<String> {
use std::io::Read;
match mode {
asset::ParseMode::WithSubAssets => {
std::fs::read_to_string(path).with_context(|| format!("read asset: {}", path.display()))
}
asset::ParseMode::TopOnly => {
const HEAD_BYTES: u64 = 4096;
let f = std::fs::File::open(path)
.with_context(|| format!("open asset: {}", path.display()))?;
let mut buf = Vec::with_capacity(HEAD_BYTES as usize);
f.take(HEAD_BYTES)
.read_to_end(&mut buf)
.with_context(|| format!("read asset: {}", path.display()))?;
if let Some(last_nl) = buf.iter().rposition(|&b| b == b'\n') {
buf.truncate(last_nl + 1);
}
String::from_utf8(buf)
.with_context(|| format!("non-utf8 asset head: {}", path.display()))
}
}
}
fn strip_meta_suffix(p: &Path) -> Option<PathBuf> {
let s = p.to_str()?;
s.strip_suffix(".meta").map(PathBuf::from)
}
fn rel_hint(project_root: &Path, companion: &Path) -> Result<String> {
let rel = companion
.strip_prefix(project_root)
.with_context(|| format!("strip prefix: {}", companion.display()))?;
let s = rel.to_string_lossy().replace('\\', "/");
Ok(s)
}
fn filename_stem(p: &Path) -> String {
p.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string()
}
fn filename_stem_from_hint(hint: &str) -> String {
Path::new(hint)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string()
}
fn disambiguate(
stem: &str,
hint: &str,
owner_guid: u128,
asset_type: AssetTypeRaw,
taken: &AHashMap<(String, AssetTypeRaw), u128>,
) -> Result<String> {
let parts: Vec<&str> = Path::new(hint)
.parent()
.map(|p| p.iter().filter_map(|c| c.to_str()).collect::<Vec<_>>())
.unwrap_or_default();
let mut suffix = String::new();
for seg in parts.iter().rev() {
if !suffix.is_empty() {
suffix.insert(0, '/');
}
suffix.insert_str(0, seg);
let candidate = format!("{stem}^{suffix}");
match taken.get(&(candidate.clone(), asset_type)) {
None => return Ok(candidate),
Some(&prev) if prev == owner_guid => return Ok(candidate),
Some(_) => continue,
}
}
anyhow::bail!(
"asset-db: cannot disambiguate name `{stem}` for guid {owner_guid:032x} \
(hint `{hint}`) — every parent-segment suffix is already taken by \
another asset. Rename one of the colliding assets in source.",
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_with_panic_safety_passes_through_ok_some() {
let r: Result<Option<i32>, String> = run_with_panic_safety("path", "task", || Ok(Some(42)));
assert_eq!(r, Ok(Some(42)));
}
#[test]
fn run_with_panic_safety_passes_through_ok_none() {
let r: Result<Option<i32>, String> = run_with_panic_safety("path", "task", || Ok(None));
assert_eq!(r, Ok(None));
}
#[test]
fn run_with_panic_safety_formats_inner_error_with_label() {
let r: Result<Option<i32>, String> = run_with_panic_safety("foo.meta", "task", || {
Err(anyhow::anyhow!("malformed yaml"))
});
assert_eq!(r, Err("foo.meta: malformed yaml".to_string()));
}
#[test]
fn run_with_panic_safety_catches_str_panic() {
let r: Result<Option<i32>, String> =
run_with_panic_safety("foo.meta", "process_one", || {
std::panic::panic_any("boom (&str payload)")
});
assert_eq!(
r,
Err("foo.meta: panic in process_one: boom (&str payload)".to_string())
);
}
#[test]
fn run_with_panic_safety_catches_string_panic() {
let r: Result<Option<i32>, String> =
run_with_panic_safety("foo.meta", "process_one", || {
panic!("formatted {}", "msg")
});
assert_eq!(
r,
Err("foo.meta: panic in process_one: formatted msg".to_string())
);
}
#[test]
fn run_with_panic_safety_handles_non_string_panic_payload() {
let r: Result<Option<i32>, String> =
run_with_panic_safety("foo.meta", "process_one", || std::panic::panic_any(42_i32));
assert_eq!(
r,
Err("foo.meta: panic in process_one: <non-string panic payload>".to_string())
);
}
fn meta_for(
texture_type: Option<u32>,
sprite_mode: Option<u32>,
sprites: Vec<(i64, String)>,
) -> meta::MetaInfo {
meta::MetaInfo {
guid: 0,
sprite_sheet: sprites,
texture_type,
sprite_mode,
}
}
#[test]
fn synthesize_implicit_sprite_fires_on_single_mode_sprite_with_empty_sheet() {
let m = meta_for(Some(TEXTURE_TYPE_SPRITE), Some(SPRITE_MODE_SINGLE), vec![]);
let sub = synthesize_implicit_sprite(&m, "Icon").expect("synthesis should fire");
assert_eq!(sub.file_id, ClassId::Sprite.canonical_subobject_fid());
assert_eq!(&*sub.name, "Icon");
}
#[test]
fn synthesize_implicit_sprite_skips_when_sheet_non_empty() {
let m = meta_for(
Some(TEXTURE_TYPE_SPRITE),
Some(SPRITE_MODE_SINGLE),
vec![(12345, "explicit_a".into())],
);
assert!(synthesize_implicit_sprite(&m, "Icon").is_none());
}
#[test]
fn synthesize_implicit_sprite_skips_on_multiple_mode() {
let m = meta_for(Some(TEXTURE_TYPE_SPRITE), Some(2), vec![]);
assert!(synthesize_implicit_sprite(&m, "Icon").is_none());
}
#[test]
fn synthesize_implicit_sprite_skips_on_non_sprite_texture() {
let m = meta_for(Some(0), Some(SPRITE_MODE_SINGLE), vec![]);
assert!(synthesize_implicit_sprite(&m, "Icon").is_none());
}
#[test]
fn synthesize_implicit_sprite_skips_when_predicates_absent() {
let m = meta_for(None, None, vec![]);
assert!(synthesize_implicit_sprite(&m, "Icon").is_none());
}
#[test]
fn is_filterable_subdoc_for_ext_branches_correctly() {
for cls in [1, 4, 224, 114] {
assert!(
is_filterable_subdoc_for_ext(cls, "prefab"),
"class {cls} should be filtered for .prefab",
);
}
assert!(!is_filterable_subdoc_for_ext(114, "playable"));
assert!(is_filterable_subdoc_for_ext(1, "playable"));
assert!(!is_filterable_subdoc_for_ext(1102, "controller"));
assert!(!is_filterable_subdoc_for_ext(114, "controller"));
assert!(!is_filterable_subdoc_for_ext(273, "mixer"));
assert!(!is_filterable_subdoc_for_ext(114, "mixer"));
assert!(!is_filterable_subdoc_for_ext(114, "asset"));
assert!(!is_filterable_subdoc_for_ext(213, "spriteatlas"));
}
#[test]
fn stem_basic() {
assert_eq!(filename_stem(Path::new("foo/Bar.prefab")), "Bar");
assert_eq!(filename_stem_from_hint("foo/Bar.prefab"), "Bar");
}
#[test]
fn disambiguate_walks_parents() {
let t = AssetTypeRaw::Native(ClassId::Texture2D as u32);
let mut taken = AHashMap::new();
taken.insert(("Foo".to_string(), t), 1u128);
let alias = disambiguate("Foo", "pkg/Editor/Foo.cs", 2, t, &taken).unwrap();
assert_eq!(alias, "Foo^Editor");
taken.insert(("Foo^Editor".to_string(), t), 3);
let alias = disambiguate("Foo", "pkg/Editor/Foo.cs", 2, t, &taken).unwrap();
assert_eq!(alias, "Foo^pkg/Editor");
}
#[test]
fn disambiguate_ignores_collisions_in_other_types() {
let png = AssetTypeRaw::Native(ClassId::Texture2D as u32);
let prefab = AssetTypeRaw::Native(ClassId::Prefab as u32);
let mut taken = AHashMap::new();
taken.insert(("Foo".to_string(), png), 1u128);
let alias = disambiguate("Foo", "Assets/Bar/Foo.prefab", 2, prefab, &taken).unwrap();
assert_eq!(alias, "Foo^Bar");
}
#[test]
fn disambiguate_returns_existing_when_same_owner() {
let t = AssetTypeRaw::Native(ClassId::Texture2D as u32);
let mut taken = AHashMap::new();
taken.insert(("Cloud1".to_string(), t), 0xa0_u128);
taken.insert(("Cloud1^Tower".to_string(), t), 0xb0_u128);
let alias =
disambiguate("Cloud1", "Assets/Tower/Cloud1.png", 0xb0_u128, t, &taken).unwrap();
assert_eq!(alias, "Cloud1^Tower");
}
#[test]
fn disambiguate_hard_fails_when_no_parent_segments() {
let t = AssetTypeRaw::Native(ClassId::Texture2D as u32);
let mut taken = AHashMap::new();
taken.insert(("Foo".to_string(), t), 1u128);
let err =
disambiguate("Foo", "Foo.cs", 2u128, t, &taken).expect_err("must hard-fail");
let msg = format!("{err:#}");
assert!(msg.contains("disambiguate"), "msg: {msg}");
assert!(msg.contains("Foo"), "msg: {msg}");
}
fn raw_native(hint: &str, guid: u128, sub_assets: Vec<SubAsset>) -> RawEntry {
RawEntry {
guid,
asset_type_raw: AssetTypeRaw::Native(ClassId::Texture2D as u32),
hint: hint.to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets,
}
}
#[test]
fn build_db_renames_every_claimant_when_name_is_contested() {
let png_a_guid = 0xa0_u128;
let png_b_guid = 0xb0_u128;
let sprite_fid: i64 = 21300000;
let raw = vec![
raw_native("Assets/Other/Cloud1.png", png_a_guid, vec![]),
raw_native(
"Assets/Tower/Cloud1.png",
png_b_guid,
vec![SubAsset {
file_id: sprite_fid,
class_id: ClassId::Sprite as u32,
name: "Cloud1".into(),
}],
),
];
let db = build_db(raw, None, None, false).expect("build_db should succeed");
let a_entry = db.find_by_guid(png_a_guid).unwrap();
let b_entry = db.find_by_guid(png_b_guid).unwrap();
assert_ne!(&*a_entry.name, "Cloud1");
assert_ne!(&*b_entry.name, "Cloud1");
assert!(
a_entry.name.starts_with("Cloud1^"),
"first png top-level not deduped: {}",
a_entry.name,
);
assert!(
b_entry.name.starts_with("Cloud1^"),
"second png top-level not deduped: {}",
b_entry.name,
);
assert_ne!(&*a_entry.name, &*b_entry.name);
let png_b_sub = &b_entry.sub_assets[0];
assert_eq!(png_b_sub.file_id, sprite_fid);
assert_eq!(
&*png_b_sub.name, "Cloud1",
"Sprite sub-asset should stay bare under type-aware dedup",
);
}
#[test]
fn build_db_keeps_bare_alias_for_type_distinct_collisions() {
let png_guid = 0xa0_u128;
let prefab_guid = 0xb0_u128;
let raw = vec![
RawEntry {
guid: png_guid,
asset_type_raw: AssetTypeRaw::Native(ClassId::Texture2D as u32),
hint: "Assets/UI/Foo.png".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![],
},
RawEntry {
guid: prefab_guid,
asset_type_raw: AssetTypeRaw::Native(ClassId::Prefab as u32),
hint: "Assets/UI/Foo.prefab".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![],
},
];
let db = build_db(raw, None, None, false).expect("build_db should succeed");
assert_eq!(&*db.find_by_guid(png_guid).unwrap().name, "Foo");
assert_eq!(&*db.find_by_guid(prefab_guid).unwrap().name, "Foo");
}
#[test]
fn build_db_skips_controller_embedded_subassets_in_global_pool() {
const ANIMATOR_STATE_CLASS_ID: u32 = 1102;
let controller_guid = 0xc0_u128;
let other_state_guid = 0xd0_u128;
let raw = vec![
RawEntry {
guid: controller_guid,
asset_type_raw: AssetTypeRaw::Native(ClassId::AnimatorController as u32),
hint: "Assets/Anim/Player.controller".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![SubAsset {
file_id: -123_456_789_012,
class_id: ANIMATOR_STATE_CLASS_ID,
name: "Idle".into(),
}],
},
RawEntry {
guid: other_state_guid,
asset_type_raw: AssetTypeRaw::Native(ANIMATOR_STATE_CLASS_ID),
hint: "Assets/Other/Idle.asset".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![],
},
];
let db = build_db(raw, None, None, false).expect("build_db should succeed");
assert_eq!(&*db.find_by_guid(other_state_guid).unwrap().name, "Idle");
let ctrl_entry = db.find_by_guid(controller_guid).unwrap();
assert_eq!(&*ctrl_entry.sub_assets[0].name, "Idle");
}
#[test]
fn build_db_skips_mixer_embedded_subassets_in_global_pool() {
const AUDIO_MIXER_GROUP_CLASS_ID: u32 = 273;
let mixer_guid = 0xe0_u128;
let other_group_guid = 0xf0_u128;
let raw = vec![
RawEntry {
guid: mixer_guid,
asset_type_raw: AssetTypeRaw::Native(ClassId::AudioMixerController as u32),
hint: "Assets/Audio/Main.mixer".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![SubAsset {
file_id: 9_001,
class_id: AUDIO_MIXER_GROUP_CLASS_ID,
name: "Master".into(),
}],
},
RawEntry {
guid: other_group_guid,
asset_type_raw: AssetTypeRaw::Native(AUDIO_MIXER_GROUP_CLASS_ID),
hint: "Assets/Other/Master.asset".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![],
},
];
let db = build_db(raw, None, None, false).expect("build_db should succeed");
assert_eq!(&*db.find_by_guid(other_group_guid).unwrap().name, "Master");
let mixer_entry = db.find_by_guid(mixer_guid).unwrap();
assert_eq!(&*mixer_entry.sub_assets[0].name, "Master");
}
#[test]
fn build_db_skips_playable_embedded_tracks_in_global_pool() {
const ANIMATION_TRACK_CLASS_ID: u32 = 5004;
let some_script_guid = 0xd21dcc2386d650c4597f3633c75a1f98_u128;
let pa_guid = 0xa0_u128;
let pb_guid = 0xb0_u128;
let raw = vec![
RawEntry {
guid: pa_guid,
asset_type_raw: AssetTypeRaw::Script(some_script_guid),
hint: "Assets/Anim/PlayableA.playable".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![SubAsset {
file_id: -123_456_789,
class_id: ANIMATION_TRACK_CLASS_ID,
name: "Animation Track (2)".into(),
}],
},
RawEntry {
guid: pb_guid,
asset_type_raw: AssetTypeRaw::Script(some_script_guid),
hint: "Assets/Anim/PlayableB.playable".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![SubAsset {
file_id: -987_654_321,
class_id: ANIMATION_TRACK_CLASS_ID,
name: "Animation Track (2)".into(),
}],
},
];
let db = build_db(raw, None, None, false).expect("build_db should succeed");
assert_eq!(
&*db.find_by_guid(pa_guid).unwrap().sub_assets[0].name,
"Animation Track (2)"
);
assert_eq!(
&*db.find_by_guid(pb_guid).unwrap().sub_assets[0].name,
"Animation Track (2)"
);
}
#[test]
fn build_db_skips_prefab_embedded_subassets_in_global_pool() {
let prefab_guid = 0xa0_u128;
let other_clip_guid = 0xb0_u128;
let raw = vec![
RawEntry {
guid: prefab_guid,
asset_type_raw: AssetTypeRaw::Native(ClassId::Prefab as u32),
hint: "Assets/UI/PatternBG.prefab".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![SubAsset {
file_id: -4_468_419_427_481_386_445,
class_id: ClassId::AnimationClip as u32,
name: "Animation".into(),
}],
},
RawEntry {
guid: other_clip_guid,
asset_type_raw: AssetTypeRaw::Native(ClassId::AnimationClip as u32),
hint: "Assets/Other/Animation.anim".to_string(),
name: String::new(),
meta_mtime_ns: 0,
asset_mtime_ns: 0,
sub_assets: vec![],
},
];
let db = build_db(raw, None, None, false).expect("build_db should succeed");
assert_eq!(
&*db.find_by_guid(other_clip_guid).unwrap().name,
"Animation"
);
let prefab_entry = db.find_by_guid(prefab_guid).unwrap();
assert_eq!(&*prefab_entry.sub_assets[0].name, "Animation");
}
#[test]
fn build_db_keeps_bare_alias_when_name_is_uncontested() {
let png_guid = 0xb0_u128;
let raw = vec![raw_native(
"Assets/Tower/Lone.png",
png_guid,
vec![SubAsset {
file_id: 21300000,
class_id: ClassId::Sprite as u32,
name: "Lone".into(),
}],
)];
let db = build_db(raw, None, None, false).expect("build_db should succeed");
let entry = db.find_by_guid(png_guid).unwrap();
assert_eq!(&*entry.name, "Lone");
assert_eq!(&*entry.sub_assets[0].name, "Lone");
}
#[test]
fn build_db_fails_when_dedup_cannot_resolve() {
let raw = vec![
raw_native("Foo.asset", 0x01_u128, vec![]),
raw_native("Foo.prefab", 0x02_u128, vec![]),
];
let err = build_db(raw, None, None, false).expect_err("collision with no parent dirs must hard-fail");
let msg = format!("{err:#}");
assert!(
msg.contains("Foo") && msg.contains("disambiguate"),
"error message should name the collision and the dedup pass: {msg}",
);
}
}