//! Explicit, transactional project content upgrade support. use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; use shared::{ AuthoringComponentStates, ComponentInstanceId, InspectorOrder, MaterialAsset, MaterialInstanceAsset, MaterialRef, RendererMaterialSlot, ShaderSchemaAsset, StaticMeshRenderer, COMPONENT_STATIC_MESH_RENDERER, }; use crate::document::{SceneComponentBlob, SceneDocument}; const INSPECTOR_ORDER_COMPONENT: &str = "shared::components::InspectorOrder"; const AUTHORING_COMPONENT_STATES_COMPONENT: &str = "shared::components::AuthoringComponentStates"; #[derive(Debug, Clone, Serialize)] pub struct ProjectUpgradeChange { pub path: String, pub kind: String, } #[derive(Debug, Clone, Serialize)] pub struct ProjectUpgradeReport { pub applied: bool, pub backup_path: Option, pub changes: Vec, pub warnings: Vec, } struct PendingWrite { path: PathBuf, contents: String, kind: &'static str, } pub fn upgrade_project(root: &Path, apply: bool) -> Result { let assets = root.join("assets"); let mut files = Vec::new(); collect_files(&assets, &mut files)?; files.sort(); let mut pending = Vec::new(); let mut warnings = Vec::new(); for path in files { if path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with('.')) { continue; } let relative = path.strip_prefix(root).unwrap_or(&path); let normalized = relative.to_string_lossy().replace('\\', "/"); let Some(original) = fs::read_to_string(&path).ok() else { continue; }; let upgraded = if normalized.ends_with(".scn.ron") || normalized.ends_with(".prefab.ron") { match canonical_scene_document(&original) { Ok(value) => Some((value, "scene-schema")), Err(error) => { warnings.push(format!("{normalized}: {error}")); None } } } else if normalized.contains("/materials/") && normalized.ends_with(".ron") { canonical_material_document(&path, &original) } else if normalized.contains("/shaders/") && normalized.ends_with(".shader.ron") { canonical_shader_document(&original) } else { None }; let Some((contents, kind)) = upgraded else { continue; }; if normalize_text(&contents) != normalize_text(&original) { pending.push(PendingWrite { path, contents, kind, }); } } let changes = pending .iter() .map(|write| ProjectUpgradeChange { path: write .path .strip_prefix(root) .unwrap_or(&write.path) .to_string_lossy() .replace('\\', "/"), kind: write.kind.to_string(), }) .collect::>(); if !apply || pending.is_empty() { return Ok(ProjectUpgradeReport { applied: false, backup_path: None, changes, warnings, }); } let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|error| error.to_string())? .as_secs(); let backup_root = root .join(".blacksite") .join("backups") .join(format!("material-component-v4-{timestamp}")); for write in &pending { let relative = write .path .strip_prefix(root) .map_err(|error| error.to_string())?; let backup = backup_root.join(relative); if let Some(parent) = backup.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } fs::copy(&write.path, &backup).map_err(|error| { format!( "could not back up {} to {}: {error}", write.path.display(), backup.display() ) })?; } let mut staged = Vec::new(); for (index, write) in pending.iter().enumerate() { let temporary = write .path .with_extension(format!("blacksite-upgrade-{index}.tmp")); fs::write(&temporary, &write.contents) .map_err(|error| format!("could not stage {}: {error}", write.path.display()))?; staged.push((temporary, write.path.clone())); } for (temporary, destination) in &staged { if let Err(error) = fs::rename(temporary, destination) { for change in &changes { let backup = backup_root.join(&change.path); let destination = root.join(&change.path); let _ = fs::copy(backup, destination); } return Err(format!( "project upgrade failed while replacing {} and was rolled back: {error}", destination.display() )); } } Ok(ProjectUpgradeReport { applied: true, backup_path: Some( backup_root .strip_prefix(root) .unwrap_or(&backup_root) .to_string_lossy() .replace('\\', "/"), ), changes, warnings, }) } fn canonical_scene_document(text: &str) -> Result { let mut document = SceneDocument::from_ron_text(text)?; for entity in &mut document.entities { let present_types = entity .components .iter() .filter(|component| shared::authoring_component_id(&component.type_name).is_some()) .map(|component| component.type_name.clone()) .collect::>(); if let Some(component) = entity .components .iter_mut() .find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER) { let mut renderer: StaticMeshRenderer = ron::from_str(&component.ron).map_err(|error| { format!("could not upgrade StaticMeshRenderer component: {error}") })?; normalize_static_renderer(&mut renderer); *component = SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?; } let states_index = entity .components .iter() .position(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT); let mut states = states_index .and_then(|index| { ron::from_str::(&entity.components[index].ron).ok() }) .unwrap_or_default(); normalize_component_states(&mut states); let mut migrated_legacy_state = false; if let Some(order_index) = entity .components .iter() .position(|component| component.type_name == INSPECTOR_ORDER_COMPONENT) { let mut order: InspectorOrder = ron::from_str(&entity.components[order_index].ron) .map_err(|error| format!("could not upgrade InspectorOrder component: {error}"))?; for legacy in std::mem::take(&mut order.component_states) { let key = if legacy.component_id.trim().is_empty() { legacy.type_name } else { legacy.component_id }; if !key.trim().is_empty() { states.set_component_active(key, legacy.active); migrated_legacy_state = true; } } let present = present_types.iter().map(String::as_str).collect::>(); order.ensure_component_order(&present); entity.components[order_index] = SceneComponentBlob::from_serializable(INSPECTOR_ORDER_COMPONENT, &order)?; } if states_index.is_some() || migrated_legacy_state { let component = SceneComponentBlob::from_serializable( AUTHORING_COMPONENT_STATES_COMPONENT, &states, )?; if let Some(index) = states_index { entity.components[index] = component; } else { entity.components.push(component); } } } document.to_ron_text() } fn normalize_static_renderer(renderer: &mut StaticMeshRenderer) { for (index, part) in renderer.slots.iter_mut().enumerate() { if part.id.is_empty() { let id = if part.mesh.sub_asset_id.trim().is_empty() { format!("draw:legacy:{index}") } else { part.mesh.sub_asset_id.clone() }; part.id = ComponentInstanceId::new(id); } if part.material_slot_id.is_empty() { part.material_slot_id = ComponentInstanceId::new(format!("slot:{}", part.id.0)); } let legacy_material = part.material.take().map(MaterialRef::new); if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) { if slot.source_material.is_none() { slot.source_material = legacy_material; } if slot.name.trim().is_empty() { slot.name = part.name.clone(); } } else { renderer.materials.slots.push(RendererMaterialSlot { id: part.material_slot_id.clone(), name: part.name.clone(), source_material: legacy_material, material: None, }); } } } fn normalize_component_states(states: &mut AuthoringComponentStates) { let old = std::mem::take(&mut states.states); for state in old { let key = if state.component_id.trim().is_empty() { state.type_name } else { state.component_id }; if !key.trim().is_empty() { states.set_component_active(key, state.active); } } } fn canonical_material_document(path: &Path, text: &str) -> Option<(String, &'static str)> { if let Ok(asset) = ron::from_str::(text) { return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "material-schema")); } if let Ok(instance) = ron::from_str::(text) { return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "material-instance-schema")); } let _ = path; None } fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> { let schema = ron::from_str::(text).ok()?; ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "surface-shader-schema")) } fn collect_files(path: &Path, output: &mut Vec) -> Result<(), String> { if !path.exists() { return Ok(()); } for entry in fs::read_dir(path).map_err(|error| error.to_string())? { let entry = entry.map_err(|error| error.to_string())?; let path = entry.path(); if entry .file_type() .map_err(|error| error.to_string())? .is_dir() { collect_files(&path, output)?; } else { output.push(path); } } Ok(()) } fn normalize_text(text: &str) -> String { text.trim().replace("\r\n", "\n") } #[cfg(test)] mod tests { use super::*; use shared::{ EditorAssetRef, InspectorComponentState, MeshRenderSlot, AUTHORING_COMPONENT_STATIC_MESH_RENDERER, }; #[test] fn dry_run_does_not_write_and_apply_creates_backup() { let root = std::env::temp_dir().join(format!( "blacksite-upgrader-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let level = root.join("assets/levels/main.scn.ron"); fs::create_dir_all(level.parent().unwrap()).unwrap(); fs::write(&level, "(schema_version: 3, resources: {}, entities: {})").unwrap(); let dry_run = upgrade_project(&root, false).unwrap(); assert!(!dry_run.applied); assert_eq!(dry_run.changes.len(), 1); assert!(fs::read_to_string(&level) .unwrap() .contains("schema_version: 3")); let applied = upgrade_project(&root, true).unwrap(); assert!(applied.applied); assert!(fs::read_to_string(&level) .unwrap() .contains("schema_version: 4")); assert!(root.join(applied.backup_path.unwrap()).exists()); fs::remove_dir_all(root).unwrap(); } #[test] fn canonical_upgrade_materializes_slots_and_separates_active_state() { let renderer = StaticMeshRenderer { slots: vec![MeshRenderSlot { id: ComponentInstanceId::new("draw:body"), name: "Body".into(), mesh: EditorAssetRef::new("model", "mesh:body", "Body"), material: Some(EditorAssetRef::new( "model", "material:body", "Body Material", )), ..Default::default() }], ..Default::default() }; let order = InspectorOrder { component_type_names: vec![COMPONENT_STATIC_MESH_RENDERER.into()], component_states: vec![InspectorComponentState { type_name: COMPONENT_STATIC_MESH_RENDERER.into(), active: false, ..Default::default() }], ..Default::default() }; let text = format!( "(schema_version: 3, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_STATIC_MESH_RENDERER}\": {}, \"{INSPECTOR_ORDER_COMPONENT}\": {}}})}})", ron::to_string(&renderer).unwrap(), ron::to_string(&order).unwrap(), ); let upgraded = canonical_scene_document(&text).unwrap(); let document = SceneDocument::from_ron_text(&upgraded).unwrap(); let components = &document.entities[0].components; let renderer: StaticMeshRenderer = ron::from_str( &components .iter() .find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER) .unwrap() .ron, ) .unwrap(); assert!(renderer.slots[0].material.is_none()); assert_eq!(renderer.materials.slots.len(), 1); assert_eq!( renderer.materials.slots[0] .source_material .as_ref() .unwrap() .0 .sub_asset_id, "material:body" ); let order: InspectorOrder = ron::from_str( &components .iter() .find(|component| component.type_name == INSPECTOR_ORDER_COMPONENT) .unwrap() .ron, ) .unwrap(); assert!(order.component_states.is_empty()); assert_eq!( order.component_ids, vec![AUTHORING_COMPONENT_STATIC_MESH_RENDERER] ); let states: AuthoringComponentStates = ron::from_str( &components .iter() .find(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT) .unwrap() .ron, ) .unwrap(); assert!(!states.is_component_active(COMPONENT_STATIC_MESH_RENDERER)); } }