use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::reload::FileWatcher;
const MAX_ASSET_FILE_SIZE: u64 = 256 * 1024 * 1024;
#[must_use]
pub fn validate_asset_path(path: &Path) -> Option<PathBuf> {
for component in path.components() {
if let std::path::Component::ParentDir = component {
tracing::warn!(
path = %path.display(),
"rejected asset path containing '..' traversal component"
);
return None;
}
}
if let Ok(canonical) = path.canonicalize()
&& let Ok(cwd) = std::env::current_dir()
{
let cwd_canonical = cwd.canonicalize().unwrap_or(cwd);
if !canonical.starts_with(&cwd_canonical) {
tracing::warn!(
path = %path.display(),
canonical = %canonical.display(),
cwd = %cwd_canonical.display(),
"rejected asset path that escapes working directory"
);
return None;
}
}
Some(path.to_path_buf())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AssetHandle(pub u64);
impl AssetHandle {
pub const NONE: Self = Self(0);
#[must_use]
#[inline]
pub fn is_valid(self) -> bool {
self.0 != 0
}
}
#[derive(Debug, Clone)]
pub struct AssetEntry {
pub handle: AssetHandle,
pub path: PathBuf,
pub asset_type: AssetType,
pub size_bytes: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AssetType {
Texture,
Sound,
Scene,
Shader,
Model,
Script,
Other,
}
impl AssetType {
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"png" | "jpg" | "jpeg" | "bmp" | "tga" => Self::Texture,
"wav" | "ogg" | "mp3" | "flac" => Self::Sound,
"toml" => Self::Scene,
"wgsl" | "glsl" | "hlsl" => Self::Shader,
"gltf" | "glb" | "obj" | "fbx" => Self::Model,
"wasm" => Self::Script,
_ => Self::Other,
}
}
}
pub struct AssetRegistry {
path_to_handle: HashMap<PathBuf, AssetHandle>,
entries: HashMap<AssetHandle, AssetEntry>,
next_id: u64,
watcher: FileWatcher,
dirty: Vec<AssetHandle>,
}
impl AssetRegistry {
pub fn new() -> Self {
Self {
path_to_handle: HashMap::new(),
entries: HashMap::new(),
next_id: 1, watcher: FileWatcher::new(),
dirty: Vec::new(),
}
}
pub fn register(&mut self, path: impl AsRef<Path>, asset_type: AssetType) -> AssetHandle {
let raw_path = path.as_ref();
let path = match validate_asset_path(raw_path) {
Some(p) => p,
None => return AssetHandle::NONE,
};
if let Some(&handle) = self.path_to_handle.get(&path) {
return handle;
}
let handle = AssetHandle(self.next_id);
self.next_id += 1;
let size_bytes = std::fs::metadata(&path)
.map(|m| m.len() as usize)
.unwrap_or(0);
self.entries.insert(
handle,
AssetEntry {
handle,
path: path.clone(),
asset_type,
size_bytes,
},
);
self.path_to_handle.insert(path.clone(), handle);
let _ = self.watcher.watch(&path);
handle
}
pub fn register_auto(&mut self, path: impl AsRef<Path>) -> AssetHandle {
let path = path.as_ref();
let asset_type = path
.extension()
.and_then(|e| e.to_str())
.map(AssetType::from_extension)
.unwrap_or(AssetType::Other);
self.register(path, asset_type)
}
#[must_use]
pub fn handle_for(&self, path: impl AsRef<Path>) -> Option<AssetHandle> {
self.path_to_handle.get(path.as_ref()).copied()
}
#[must_use]
pub fn get(&self, handle: AssetHandle) -> Option<&AssetEntry> {
self.entries.get(&handle)
}
#[must_use]
pub fn path_for(&self, handle: AssetHandle) -> Option<&Path> {
self.entries.get(&handle).map(|e| e.path.as_path())
}
#[must_use]
#[inline]
pub fn count(&self) -> usize {
self.entries.len()
}
pub fn poll_changes(&mut self) -> Vec<AssetHandle> {
let changed_paths = self.watcher.poll_changes();
self.dirty.clear();
for path in changed_paths {
if let Some(&handle) = self.path_to_handle.get(&path) {
self.dirty.push(handle);
}
}
self.dirty.clone()
}
#[must_use]
pub fn is_dirty(&self, handle: AssetHandle) -> bool {
self.dirty.contains(&handle)
}
#[must_use]
pub fn total_size_bytes(&self) -> usize {
self.entries.values().map(|e| e.size_bytes).sum()
}
#[must_use]
pub fn assets_of_type(&self, asset_type: AssetType) -> Vec<AssetHandle> {
self.entries
.values()
.filter(|e| e.asset_type == asset_type)
.map(|e| e.handle)
.collect()
}
}
impl Default for AssetRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LoadStatus {
Pending,
Loading,
Ready,
Failed,
}
#[derive(Debug)]
pub struct AsyncLoadRequest {
pub handle: AssetHandle,
pub path: PathBuf,
pub status: LoadStatus,
pub data: Option<Vec<u8>>,
pub error: Option<String>,
}
pub struct AsyncAssetLoader {
requests: Vec<AsyncLoadRequest>,
completed: Vec<AsyncLoadRequest>,
}
impl Default for AsyncAssetLoader {
fn default() -> Self {
Self::new()
}
}
impl AsyncAssetLoader {
pub fn new() -> Self {
Self {
requests: Vec::new(),
completed: Vec::new(),
}
}
pub fn load(&mut self, handle: AssetHandle, path: impl AsRef<Path>) {
let path = path.as_ref().to_path_buf();
tracing::debug!(handle = handle.0, path = %path.display(), "queued async load");
self.requests.push(AsyncLoadRequest {
handle,
path,
status: LoadStatus::Pending,
data: None,
error: None,
});
}
pub fn poll(&mut self, max_per_frame: usize) {
let mut processed = 0;
for request in &mut self.requests {
if processed >= max_per_frame {
break;
}
if request.status != LoadStatus::Pending {
continue;
}
request.status = LoadStatus::Loading;
if let Ok(meta) = std::fs::metadata(&request.path)
&& meta.len() > MAX_ASSET_FILE_SIZE
{
request.status = LoadStatus::Failed;
request.error = Some(format!(
"asset file too large: {} bytes (max {} bytes)",
meta.len(),
MAX_ASSET_FILE_SIZE
));
tracing::warn!(
handle = request.handle.0,
path = %request.path.display(),
size = meta.len(),
max = MAX_ASSET_FILE_SIZE,
"asset exceeds maximum file size limit"
);
processed += 1;
continue;
}
match std::fs::read(&request.path) {
Ok(data) => {
request.status = LoadStatus::Ready;
let size = data.len();
request.data = Some(data);
tracing::info!(
handle = request.handle.0,
path = %request.path.display(),
size,
"asset loaded"
);
}
Err(e) => {
request.status = LoadStatus::Failed;
request.error = Some(e.to_string());
tracing::warn!(
handle = request.handle.0,
path = %request.path.display(),
error = %e,
"asset load failed"
);
}
}
processed += 1;
}
let mut i = 0;
while i < self.requests.len() {
if self.requests[i].status == LoadStatus::Ready
|| self.requests[i].status == LoadStatus::Failed
{
self.completed.push(self.requests.swap_remove(i));
} else {
i += 1;
}
}
}
pub fn drain_completed(&mut self) -> Vec<AsyncLoadRequest> {
std::mem::take(&mut self.completed)
}
#[must_use]
#[inline]
pub fn pending_count(&self) -> usize {
self.requests.len()
}
#[must_use]
#[inline]
pub fn completed_count(&self) -> usize {
self.completed.len()
}
#[must_use]
#[inline]
pub fn is_busy(&self) -> bool {
!self.requests.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PreprocessStep {
CompressTexture {
format: String,
quality: u8,
},
OptimizeMesh,
GenerateMipmaps,
StripAnimations,
Custom {
command: String,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PreprocessPipeline {
pub steps: Vec<(AssetType, PreprocessStep)>,
}
impl PreprocessPipeline {
pub fn new() -> Self {
Self::default()
}
pub fn add_step(&mut self, asset_type: AssetType, step: PreprocessStep) {
self.steps.push((asset_type, step));
}
#[must_use]
pub fn steps_for(&self, asset_type: AssetType) -> Vec<&PreprocessStep> {
self.steps
.iter()
.filter(|(t, _)| *t == asset_type)
.map(|(_, s)| s)
.collect()
}
pub fn process(&self, asset_type: AssetType, data: Vec<u8>) -> (Vec<u8>, Vec<String>) {
let steps = self.steps_for(asset_type);
let mut log = Vec::new();
if steps.is_empty() {
return (data, log);
}
let result = data;
for step in steps {
match step {
PreprocessStep::CompressTexture { format, quality } => {
log.push(format!("compress_texture({format}, q={quality})"));
}
PreprocessStep::OptimizeMesh => {
log.push("optimize_mesh".to_string());
}
PreprocessStep::GenerateMipmaps => {
log.push("generate_mipmaps".to_string());
}
PreprocessStep::StripAnimations => {
log.push("strip_animations".to_string());
}
PreprocessStep::Custom { command } => {
log.push(format!("custom({command})"));
}
}
}
(result, log)
}
#[must_use]
#[inline]
pub fn step_count(&self) -> usize {
self.steps.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_handle_none() {
assert!(!AssetHandle::NONE.is_valid());
assert!(AssetHandle(1).is_valid());
}
#[test]
fn asset_type_from_extension() {
assert_eq!(AssetType::from_extension("png"), AssetType::Texture);
assert_eq!(AssetType::from_extension("JPG"), AssetType::Texture);
assert_eq!(AssetType::from_extension("wav"), AssetType::Sound);
assert_eq!(AssetType::from_extension("toml"), AssetType::Scene);
assert_eq!(AssetType::from_extension("wgsl"), AssetType::Shader);
assert_eq!(AssetType::from_extension("glb"), AssetType::Model);
assert_eq!(AssetType::from_extension("wasm"), AssetType::Script);
assert_eq!(AssetType::from_extension("xyz"), AssetType::Other);
}
#[test]
fn registry_register() {
let mut reg = AssetRegistry::new();
let h = reg.register("textures/brick.png", AssetType::Texture);
assert!(h.is_valid());
assert_eq!(reg.count(), 1);
let entry = reg.get(h).unwrap();
assert_eq!(entry.asset_type, AssetType::Texture);
}
#[test]
fn registry_register_auto() {
let mut reg = AssetRegistry::new();
let h = reg.register_auto("sounds/explosion.wav");
let entry = reg.get(h).unwrap();
assert_eq!(entry.asset_type, AssetType::Sound);
}
#[test]
fn registry_duplicate_returns_same_handle() {
let mut reg = AssetRegistry::new();
let h1 = reg.register("test.png", AssetType::Texture);
let h2 = reg.register("test.png", AssetType::Texture);
assert_eq!(h1, h2);
assert_eq!(reg.count(), 1);
}
#[test]
fn registry_handle_for() {
let mut reg = AssetRegistry::new();
let h = reg.register("test.toml", AssetType::Scene);
assert_eq!(reg.handle_for("test.toml"), Some(h));
assert_eq!(reg.handle_for("missing.toml"), None);
}
#[test]
fn registry_path_for() {
let mut reg = AssetRegistry::new();
let h = reg.register("models/char.glb", AssetType::Model);
assert_eq!(
reg.path_for(h).unwrap().to_str().unwrap(),
"models/char.glb"
);
}
#[test]
fn registry_assets_of_type() {
let mut reg = AssetRegistry::new();
reg.register("a.png", AssetType::Texture);
reg.register("b.png", AssetType::Texture);
reg.register("c.wav", AssetType::Sound);
let textures = reg.assets_of_type(AssetType::Texture);
assert_eq!(textures.len(), 2);
let sounds = reg.assets_of_type(AssetType::Sound);
assert_eq!(sounds.len(), 1);
}
#[test]
fn registry_poll_no_changes() {
let mut reg = AssetRegistry::new();
let changes = reg.poll_changes();
assert!(changes.is_empty());
}
#[test]
fn asset_handle_serde() {
let h = AssetHandle(42);
let json = serde_json::to_string(&h).unwrap();
let decoded: AssetHandle = serde_json::from_str(&json).unwrap();
assert_eq!(h, decoded);
}
#[test]
fn asset_type_serde() {
for t in [
AssetType::Texture,
AssetType::Sound,
AssetType::Scene,
AssetType::Shader,
AssetType::Model,
AssetType::Script,
AssetType::Other,
] {
let json = serde_json::to_string(&t).unwrap();
let decoded: AssetType = serde_json::from_str(&json).unwrap();
assert_eq!(t, decoded);
}
}
#[test]
fn async_loader_new() {
let loader = AsyncAssetLoader::new();
assert_eq!(loader.pending_count(), 0);
assert_eq!(loader.completed_count(), 0);
assert!(!loader.is_busy());
}
#[test]
fn async_loader_queue() {
let mut loader = AsyncAssetLoader::new();
loader.load(AssetHandle(1), "test.png");
assert_eq!(loader.pending_count(), 1);
assert!(loader.is_busy());
}
#[test]
fn async_loader_poll_missing_file() {
let mut loader = AsyncAssetLoader::new();
loader.load(AssetHandle(1), "/nonexistent/path/file.png");
loader.poll(10);
assert_eq!(loader.pending_count(), 0);
let completed = loader.drain_completed();
assert_eq!(completed.len(), 1);
assert_eq!(completed[0].status, LoadStatus::Failed);
assert!(completed[0].error.is_some());
}
#[test]
fn async_loader_poll_limit() {
let mut loader = AsyncAssetLoader::new();
for i in 0..5 {
loader.load(AssetHandle(i + 1), format!("/missing/{i}.png"));
}
loader.poll(2); assert_eq!(loader.completed_count(), 2);
assert_eq!(loader.pending_count(), 3);
}
#[test]
fn async_loader_drain_clears() {
let mut loader = AsyncAssetLoader::new();
loader.load(AssetHandle(1), "/missing/a.png");
loader.poll(10);
assert_eq!(loader.completed_count(), 1);
let _ = loader.drain_completed();
assert_eq!(loader.completed_count(), 0);
}
#[test]
fn preprocess_pipeline_new() {
let pipeline = PreprocessPipeline::new();
assert_eq!(pipeline.step_count(), 0);
}
#[test]
fn preprocess_pipeline_add_steps() {
let mut pipeline = PreprocessPipeline::new();
pipeline.add_step(
AssetType::Texture,
PreprocessStep::CompressTexture {
format: "bc7".into(),
quality: 80,
},
);
pipeline.add_step(AssetType::Texture, PreprocessStep::GenerateMipmaps);
pipeline.add_step(AssetType::Model, PreprocessStep::OptimizeMesh);
assert_eq!(pipeline.step_count(), 3);
}
#[test]
fn preprocess_pipeline_steps_for() {
let mut pipeline = PreprocessPipeline::new();
pipeline.add_step(AssetType::Texture, PreprocessStep::GenerateMipmaps);
pipeline.add_step(AssetType::Model, PreprocessStep::OptimizeMesh);
pipeline.add_step(
AssetType::Texture,
PreprocessStep::CompressTexture {
format: "astc".into(),
quality: 90,
},
);
let tex_steps = pipeline.steps_for(AssetType::Texture);
assert_eq!(tex_steps.len(), 2);
let model_steps = pipeline.steps_for(AssetType::Model);
assert_eq!(model_steps.len(), 1);
let sound_steps = pipeline.steps_for(AssetType::Sound);
assert!(sound_steps.is_empty());
}
#[test]
fn preprocess_pipeline_process() {
let mut pipeline = PreprocessPipeline::new();
pipeline.add_step(AssetType::Texture, PreprocessStep::GenerateMipmaps);
pipeline.add_step(
AssetType::Texture,
PreprocessStep::CompressTexture {
format: "bc7".into(),
quality: 80,
},
);
let data = vec![1, 2, 3, 4];
let (result, log) = pipeline.process(AssetType::Texture, data.clone());
assert_eq!(result, data); assert_eq!(log.len(), 2);
assert!(log[0].contains("generate_mipmaps"));
assert!(log[1].contains("compress_texture"));
}
#[test]
fn preprocess_pipeline_process_no_steps() {
let pipeline = PreprocessPipeline::new();
let data = vec![1, 2, 3];
let (result, log) = pipeline.process(AssetType::Sound, data.clone());
assert_eq!(result, data);
assert!(log.is_empty());
}
#[test]
fn preprocess_pipeline_serde() {
let mut pipeline = PreprocessPipeline::new();
pipeline.add_step(AssetType::Texture, PreprocessStep::GenerateMipmaps);
pipeline.add_step(AssetType::Model, PreprocessStep::OptimizeMesh);
pipeline.add_step(
AssetType::Texture,
PreprocessStep::Custom {
command: "optipng".into(),
},
);
let json = serde_json::to_string(&pipeline).unwrap();
let decoded: PreprocessPipeline = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.step_count(), 3);
}
#[test]
fn load_status_variants() {
assert_ne!(LoadStatus::Pending, LoadStatus::Loading);
assert_ne!(LoadStatus::Ready, LoadStatus::Failed);
}
#[test]
fn registry_as_world_resource() {
let mut world = crate::World::new();
let mut reg = AssetRegistry::new();
reg.register("game.toml", AssetType::Scene);
world.insert_resource(reg);
let reg = world.get_resource::<AssetRegistry>().unwrap();
assert_eq!(reg.count(), 1);
}
}