//! Explicit, transactional project content upgrade support. use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use shared::{ AssetId, AssetKind, AssetRecord, AssetRegistryDocument, AuthoringComponentStates, ComponentInstanceId, InspectorOrder, MaterialAsset, MaterialDesc, MaterialInputGroupDesc, MaterialInputSchema, MaterialInputSet, MaterialInstanceAsset, MaterialOverride, MaterialParameter, MaterialProvenance, MaterialRef, MaterialRenderState, MaterialSlot, Primitive, ShaderPropertyType, ShaderSchemaAsset, SkinnedMeshRenderer, StaticMeshRenderer, COMPONENT_MATERIAL_DESC, COMPONENT_MATERIAL_OVERRIDE, COMPONENT_PRIMITIVE, COMPONENT_SKINNED_MESH_RENDERER, COMPONENT_STATIC_MESH_RENDERER, MATERIAL_ASSET_SCHEMA_VERSION, }; use crate::document::{SceneComponentBlob, SceneDocument}; #[path = "upgrade/io.rs"] mod io; use io::{collect_files, normalize_text}; 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, existed: bool, } struct LegacyMaterialMigration<'a> { root: &'a Path, registry: AssetRegistryDocument, material_writes: std::collections::BTreeMap, } pub fn upgrade_project(root: &Path, apply: bool) -> Result { let assets = root.join("assets"); let registry_path = assets.join(".index/registry.ron"); let registry_original = fs::read_to_string(®istry_path).unwrap_or_default(); let registry = if registry_original.trim().is_empty() { AssetRegistryDocument::default() } else { shared::parse_asset_registry(®istry_original)?.document }; let mut legacy_materials = LegacyMaterialMigration { root, registry, material_writes: std::collections::BTreeMap::new(), }; 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('\\', "/"); if normalized.starts_with("assets/.") { continue; } let registered_kind = legacy_materials .registry .records .iter() .find(|record| record.path == normalized) .map(|record| record.kind); let Some(original) = fs::read_to_string(&path).ok() else { continue; }; let upgraded = if normalized == "assets/project.ron" { canonical_project_settings(&original) } else if normalized == "assets/.index/registry.ron" { None } else if normalized.ends_with(".scn.ron") || normalized.ends_with(".prefab.ron") { match canonical_scene_document(&original, &mut legacy_materials) { Ok(value) => Some((value, "scene-schema")), Err(error) => { warnings.push(format!("{normalized}: {error}")); None } } } else if registered_kind == Some(AssetKind::ShaderSchema) || normalized.ends_with(".shader.ron") { canonical_shader_document(&original) } else if matches!( registered_kind, Some(AssetKind::Material | AssetKind::MaterialInstance) ) || normalized.ends_with(".material.ron") || normalized.ends_with(".material-instance.ron") { canonical_material_document(&path, &original, registered_kind, &mut legacy_materials) } else { None }; let Some((contents, kind)) = upgraded else { continue; }; if normalize_text(&contents) != normalize_text(&original) { pending.push(PendingWrite { path, contents, kind, existed: true, }); } } for (path, contents) in std::mem::take(&mut legacy_materials.material_writes) { let existed = path.exists(); let unchanged = existed && fs::read_to_string(&path) .is_ok_and(|original| normalize_text(&original) == normalize_text(&contents)); if !unchanged { pending.push(PendingWrite { path, contents, kind: "legacy-material-v5", existed, }); } } let registry_contents = ron::ser::to_string_pretty( &legacy_materials.registry, ron::ser::PrettyConfig::default(), ) .map_err(|error| format!("could not serialize upgraded asset registry: {error}"))?; if normalize_text(®istry_contents) != normalize_text(®istry_original) { pending.push(PendingWrite { path: registry_path, contents: registry_contents, kind: "asset-registry-v3", existed: !registry_original.is_empty(), }); } 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, }); } if !warnings.is_empty() { return Err(format!( "project upgrade is blocked by {} migration error(s): {}", warnings.len(), warnings.join("; ") )); } 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-slot-v5-{timestamp}")); for write in &pending { if !write.existed { continue; } 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() { if let Err(error) = fs::create_dir_all(parent) { let _ = fs::remove_dir_all(&backup_root); return Err(error.to_string()); } } if let Err(error) = fs::copy(&write.path, &backup) { let _ = fs::remove_dir_all(&backup_root); return Err(format!( "could not back up {} to {}: {error}", write.path.display(), backup.display() )); } } let mut staged = Vec::new(); let staging_root = backup_root.join(".staging"); if let Err(error) = fs::create_dir_all(&staging_root) { let _ = fs::remove_dir_all(&backup_root); return Err(format!( "could not prepare project upgrade staging: {error}" )); } for (index, write) in pending.iter().enumerate() { let temporary = staging_root.join(format!("{index}.tmp")); if let Err(error) = fs::write(&temporary, &write.contents) { let _ = fs::remove_dir_all(&backup_root); return Err(format!("could not stage {}: {error}", write.path.display())); } staged.push((temporary, write.path.clone())); } let mut created_directories = Vec::new(); for (temporary, destination) in &staged { if let Some(parent) = destination.parent().filter(|parent| !parent.exists()) { if let Err(error) = fs::create_dir_all(parent) { rollback_project_upgrade( root, &backup_root, &changes, &staged, &created_directories, ); return Err(format!( "could not prepare upgrade destination {}: {error}", parent.display() )); } created_directories.push(parent.to_path_buf()); } if let Err(error) = fs::rename(temporary, destination) { rollback_project_upgrade(root, &backup_root, &changes, &staged, &created_directories); return Err(format!( "project upgrade failed while replacing {} and was rolled back: {error}", destination.display() )); } } let _ = fs::remove_dir_all(&staging_root); Ok(ProjectUpgradeReport { applied: true, backup_path: Some( backup_root .strip_prefix(root) .unwrap_or(&backup_root) .to_string_lossy() .replace('\\', "/"), ), changes, warnings, }) } fn rollback_project_upgrade( root: &Path, backup_root: &Path, changes: &[ProjectUpgradeChange], staged: &[(PathBuf, PathBuf)], created_directories: &[PathBuf], ) { for change in changes { let backup = backup_root.join(&change.path); let destination = root.join(&change.path); if backup.exists() { let _ = fs::copy(backup, destination); } else { let _ = fs::remove_file(destination); } } for (temporary, _) in staged { let _ = fs::remove_file(temporary); } for directory in created_directories.iter().rev() { let _ = fs::remove_dir(directory); } let _ = fs::remove_dir_all(backup_root); } fn canonical_project_settings(text: &str) -> Option<(String, &'static str)> { let mut project = ron::from_str::(text).ok()?; project.version = settings::SETTINGS_VERSION; project.template_version = settings::PROJECT_TEMPLATE_VERSION; project.asset_roots = vec!["assets".into()]; settings::save_project_settings_to_string(&project) .ok() .map(|contents| (contents, "project-content-settings-v2")) } fn canonical_scene_document( text: &str, legacy_materials: &mut LegacyMaterialMigration<'_>, ) -> Result { let mut document = SceneDocument::from_ron_text(text)?; for entity in &mut document.entities { let legacy_material = entity .components .iter() .find(|component| component.type_name == COMPONENT_MATERIAL_DESC) .map(|component| { ron::from_str::(&component.ron) .map_err(|error| format!("could not upgrade MaterialDesc component: {error}")) }) .transpose()?; let legacy_overrides = entity .components .iter() .find(|component| component.type_name == COMPONENT_MATERIAL_OVERRIDE) .map(|component| { ron::from_str::(&component.ron).map_err(|error| { format!("could not upgrade MaterialOverride component: {error}") }) }) .transpose()?; let mut consumed_legacy_material = false; if let Some(index) = entity .components .iter() .position(|component| component.type_name == COMPONENT_PRIMITIVE) { let mut primitive: Primitive = ron::from_str(&entity.components[index].ron) .map_err(|error| format!("could not upgrade Primitive component: {error}"))?; normalize_primitive_surface(&mut primitive); if let Some(material) = legacy_material.as_ref() { primitive.surface.material = Some(legacy_materials.reference_for(material)?); consumed_legacy_material = true; } entity.components[index] = SceneComponentBlob::from_serializable(COMPONENT_PRIMITIVE, &primitive)?; } if let Some(index) = entity .components .iter() .position(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER) { let mut renderer: StaticMeshRenderer = ron::from_str(&entity.components[index].ron) .map_err(|error| { format!("could not upgrade StaticMeshRenderer component: {error}") })?; normalize_static_renderer(&mut renderer); if let Some(material) = legacy_material.as_ref() { let reference = legacy_materials.reference_for(material)?; for slot in &mut renderer.materials.slots { slot.material = Some(reference.clone()); } consumed_legacy_material = true; } else if let Some(overrides) = legacy_overrides.as_ref() { for override_slot in &overrides.slots { let Some(part) = renderer .slots .iter() .find(|part| part.id == override_slot.slot_id) else { continue; }; let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) else { continue; }; slot.material = Some(legacy_materials.reference_for(&override_slot.material)?); } } entity.components[index] = SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?; } if let Some(index) = entity .components .iter() .position(|component| component.type_name == COMPONENT_SKINNED_MESH_RENDERER) { let mut renderer: SkinnedMeshRenderer = ron::from_str(&entity.components[index].ron) .map_err(|error| { format!("could not upgrade SkinnedMeshRenderer component: {error}") })?; if let Some(material) = legacy_material.as_ref() { let reference = legacy_materials.reference_for(material)?; for slot in &mut renderer.materials.slots { slot.material = Some(reference.clone()); } consumed_legacy_material = true; } else if let Some(overrides) = legacy_overrides.as_ref() { for override_slot in &overrides.slots { if let Some(slot) = renderer.materials.slot_mut(&override_slot.slot_id) { slot.material = Some(legacy_materials.reference_for(&override_slot.material)?); } } } entity.components[index] = SceneComponentBlob::from_serializable(COMPONENT_SKINNED_MESH_RENDERER, &renderer)?; } if consumed_legacy_material || entity.components.iter().any(|component| { component.type_name == COMPONENT_STATIC_MESH_RENDERER || component.type_name == COMPONENT_SKINNED_MESH_RENDERER }) { entity.components.retain(|component| { component.type_name != COMPONENT_MATERIAL_DESC && component.type_name != COMPONENT_MATERIAL_OVERRIDE }); } let present_types = entity .components .iter() .filter(|component| shared::authoring_component_id(&component.type_name).is_some()) .map(|component| component.type_name.clone()) .collect::>(); 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)); } if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) { if slot.name.trim().is_empty() { slot.name = part.name.clone(); } } else { renderer.materials.slots.push(MaterialSlot { id: part.material_slot_id.clone(), name: part.name.clone(), material: None, }); } } } fn normalize_primitive_surface(primitive: &mut Primitive) { if primitive.surface.id.is_empty() { primitive.surface.id = ComponentInstanceId::new(shared::PRIMITIVE_SURFACE_SLOT_ID); } if primitive.surface.name.trim().is_empty() { primitive.surface.name = "Surface".into(); } } impl LegacyMaterialMigration<'_> { fn reference_for(&mut self, material: &MaterialDesc) -> Result { if let Some(path) = material.material_asset_path.as_deref() { if let Some(reference) = self.reference_for_existing(path) { return Ok(reference); } } let mut canonical = material.clone(); canonical.material_asset_path = None; let canonical_ron = ron::ser::to_string(&canonical) .map_err(|error| format!("could not canonicalize legacy material: {error}"))?; let hash = blake3::hash(canonical_ron.as_bytes()).to_hex().to_string(); let short = &hash[..16]; let relative = format!("assets/materials/migrated/legacy-{short}.material.ron"); let destination = self.root.join(&relative); let label = format!("Legacy Material {short}"); let asset = MaterialAsset { schema_version: MATERIAL_ASSET_SCHEMA_VERSION, label: label.clone(), shader: canonical.shader.clone(), shader_ref: None, render_state: shared::MaterialRenderState::default(), provenance: None, inputs: shared::MaterialInputSet::from_material_desc(&canonical), }; let contents = ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) .map_err(|error| format!("could not serialize migrated material: {error}"))?; if destination.exists() { let existing = MaterialAsset::load_from_path(destination.to_string_lossy().as_ref()) .map_err(|_| { format!( "legacy material migration collision at {}; choose another migration destination", destination.display() ) })?; if existing.inputs != shared::MaterialInputSet::from_material_desc(&canonical) { return Err(format!( "legacy material migration collision at {}", destination.display() )); } } else { self.material_writes.entry(destination).or_insert(contents); } let record_index = if let Some(index) = self .registry .records .iter() .position(|record| record.path == relative) { index } else { let digest = blake3::hash(format!("blacksite:{relative}").as_bytes()); let mut bytes = [0_u8; 16]; bytes.copy_from_slice(&digest.as_bytes()[..16]); bytes[6] = (bytes[6] & 0x0f) | 0x50; bytes[8] = (bytes[8] & 0x3f) | 0x80; self.registry.records.push(AssetRecord { id: AssetId(uuid::Uuid::from_bytes(bytes)), path: relative.clone(), label: label.clone(), kind: AssetKind::Material, source_fingerprint: None, import_settings: shared::AssetImportSettings::None, dependencies: material_dependencies(&canonical), }); self.registry.records.len() - 1 }; let record = &self.registry.records[record_index]; Ok(MaterialRef::new( shared::EditorAssetRef::new(record.id.as_string(), "material:source", &record.label) .with_source_path(&record.path), )) } fn reference_for_existing(&self, path: &str) -> Option { let record = self .registry .records .iter() .find(|record| record.path == path)?; let full = self.root.join(path); let sub_asset_id = if MaterialInstanceAsset::load_from_path(full.to_string_lossy().as_ref()).is_ok() { "material:instance" } else if MaterialAsset::load_from_path(full.to_string_lossy().as_ref()).is_ok() { "material:source" } else { return None; }; Some(MaterialRef::new( shared::EditorAssetRef::new(record.id.as_string(), sub_asset_id, &record.label) .with_source_path(path), )) } } fn material_dependencies(material: &MaterialDesc) -> Vec { let mut dependencies = [ material.base_color_texture.as_ref(), material.emissive_texture.as_ref(), material.normal_map_texture.as_ref(), material.metallic_roughness_texture.as_ref(), ] .into_iter() .flatten() .cloned() .collect::>(); dependencies.extend(material.textures.iter().filter_map(|texture| { texture .texture .as_ref() .and_then(|reference| reference.source_path.clone()) })); dependencies.sort(); dependencies.dedup(); dependencies } 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); } } } #[derive(Deserialize)] struct LegacyMaterialAssetV1 { label: String, #[serde(default)] shader: Option, #[serde(default)] shader_ref: Option, #[serde(default)] render_state: MaterialRenderState, #[serde(default)] provenance: Option, material: MaterialDesc, } #[derive(Deserialize)] struct LegacyMaterialInstanceV1 { label: String, base: MaterialRef, #[serde(default)] parameters: Vec, #[serde(default)] textures: Vec, } #[derive(Deserialize)] struct LegacyShaderSchemaV1 { label: String, kind: shared::MaterialShaderKind, #[serde(default)] wgsl_path: Option, #[serde(default)] parameters: Vec, #[serde(default)] default_values: Vec, #[serde(default)] default_textures: Vec, } #[derive(Deserialize)] struct LegacyShaderInputV1 { name: String, display_name: String, #[serde(default)] group: String, property_type: ShaderPropertyType, } fn canonical_material_document( path: &Path, text: &str, registered_kind: Option, migration: &mut LegacyMaterialMigration<'_>, ) -> Option<(String, &'static str)> { let is_instance = registered_kind == Some(AssetKind::MaterialInstance) || path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".material-instance.ron")); if is_instance { if let Ok(instance) = ron::from_str::(text) { if instance.schema_version == shared::MATERIAL_INSTANCE_SCHEMA_VERSION { return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "material-instance-schema")); } } let legacy = ron::from_str::(text).ok()?; let mut overrides = MaterialInputSet { values: legacy.parameters, textures: legacy.textures, }; resolve_texture_references(&mut overrides, &migration.registry); let instance = MaterialInstanceAsset { schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, label: legacy.label, base: legacy.base, overrides, }; update_material_instance_registry_dependencies(path, &instance, migration); return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "material-instance-schema-v2")); } if let Ok(asset) = ron::from_str::(text) { if asset.schema_version == shared::MATERIAL_ASSET_SCHEMA_VERSION { return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "material-schema")); } } if let Ok(legacy) = ron::from_str::(text) { let mut inputs = MaterialInputSet::from_material_desc(&legacy.material); resolve_texture_references(&mut inputs, &migration.registry); let mut shader = legacy.material.shader; if shader.schema_path.is_none() { shader.schema_path = legacy .shader .as_deref() .filter(|path| path.ends_with(".ron")) .map(str::to_string); } let asset = MaterialAsset { schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, label: legacy.label, shader, shader_ref: legacy.shader_ref, render_state: legacy.render_state, provenance: legacy.provenance, inputs, }; update_material_registry_dependencies(path, &asset.inputs, migration); return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "material-schema-v2")); } None } fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> { if let Ok(schema) = ron::from_str::(text) { if schema.schema_version == shared::SURFACE_SHADER_SCHEMA_VERSION { return ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "surface-shader-schema")); } } let legacy = ron::from_str::(text).ok()?; let schema = if legacy.kind == shared::MaterialShaderKind::StandardLit { shared::standard_lit_input_schema() } else { legacy_shader_input_schema(&legacy) }; let schema = ShaderSchemaAsset { schema_version: shared::SURFACE_SHADER_SCHEMA_VERSION, label: legacy.label, kind: legacy.kind, wgsl_path: legacy.wgsl_path, schema, }; ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default()) .ok() .map(|text| (text, "surface-shader-schema-v2")) } fn resolve_texture_references(inputs: &mut MaterialInputSet, registry: &AssetRegistryDocument) { for binding in &mut inputs.textures { let Some(reference) = binding.texture.as_mut() else { continue; }; let Some(path) = reference.source_path.as_deref() else { continue; }; if let Some(record) = registry.records.iter().find(|record| record.path == path) { reference.asset_id = record.id.as_string(); reference.sub_asset_id = "texture:source".into(); reference.label = record.label.clone(); } } } fn update_material_registry_dependencies( path: &Path, inputs: &MaterialInputSet, migration: &mut LegacyMaterialMigration<'_>, ) { let relative = path .strip_prefix(migration.root) .unwrap_or(path) .to_string_lossy() .replace('\\', "/"); let dependencies = inputs .textures .iter() .filter_map(|binding| binding.texture.as_ref()) .filter_map(|reference| reference.source_path.clone()) .collect::>() .into_iter() .collect::>(); if let Some(record) = migration .registry .records .iter_mut() .find(|record| record.path == relative) { record.dependencies = dependencies; } } fn update_material_instance_registry_dependencies( path: &Path, instance: &MaterialInstanceAsset, migration: &mut LegacyMaterialMigration<'_>, ) { let relative = path .strip_prefix(migration.root) .unwrap_or(path) .to_string_lossy() .replace('\\', "/"); let mut dependencies = instance .overrides .textures .iter() .filter_map(|binding| binding.texture.as_ref()) .filter_map(|reference| reference.source_path.clone()) .collect::>(); if let Some(path) = instance.base.0.source_path.as_ref() { dependencies.insert(path.clone()); } if let Some(record) = migration .registry .records .iter_mut() .find(|record| record.path == relative) { record.dependencies = dependencies.into_iter().collect(); } } fn legacy_shader_input_schema(legacy: &LegacyShaderSchemaV1) -> MaterialInputSchema { let mut group_names = Vec::new(); for input in &legacy.parameters { if !group_names.contains(&input.group) { group_names.push(input.group.clone()); } } let groups = group_names .iter() .enumerate() .map(|(index, name)| MaterialInputGroupDesc { id: legacy_group_id(name), display_name: if name.trim().is_empty() { "Properties".into() } else { name.clone() }, order: index as i32, advanced: name.to_ascii_lowercase().contains("advanced"), }) .collect::>(); let inputs = legacy .parameters .iter() .enumerate() .map(|(index, input)| shared::MaterialInputDesc { name: input.name.clone(), display_name: input.display_name.clone(), group: legacy_group_id(&input.group), order: index as i32, property_type: input.property_type.clone(), default_value: legacy .default_values .iter() .find(|value| value.name == input.name) .map(|value| value.value.clone()), texture: matches!(input.property_type, ShaderPropertyType::Texture).then(|| { let default_channel = legacy .default_textures .iter() .find(|binding| binding.name == input.name) .map(|binding| binding.channel) .unwrap_or_default(); shared::MaterialTextureInputDesc { semantic: shared::TextureSemantic::Mask, default_channel, allow_channel_override: true, } }), ..Default::default() }) .collect(); MaterialInputSchema { groups, inputs } } fn legacy_group_id(name: &str) -> String { let value = name .chars() .map(|character| { if character.is_ascii_alphanumeric() { character.to_ascii_lowercase() } else { '_' } }) .collect::(); let value = value.trim_matches('_'); if value.is_empty() { "properties".into() } else { value.into() } } #[cfg(test)] mod tests { use super::*; use shared::{ EditorAssetRef, InspectorComponentState, MaterialSlotOverride, MaterialSlotSet, MeshRenderSlot, AUTHORING_COMPONENT_STATIC_MESH_RENDERER, }; fn migration_context(root: &Path) -> LegacyMaterialMigration<'_> { LegacyMaterialMigration { root, registry: AssetRegistryDocument::default(), material_writes: std::collections::BTreeMap::new(), } } #[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(), 2); assert!(dry_run .changes .iter() .any(|change| change.path == "assets/levels/main.scn.ron")); assert!(dry_run .changes .iter() .any(|change| change.path == "assets/.index/registry.ron")); 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: 6")); 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_slot_id: ComponentInstanceId::new("slot:draw:body"), ..Default::default() }], materials: MaterialSlotSet { slots: vec![MaterialSlot { id: ComponentInstanceId::new("slot:draw:body"), name: "Body".into(), material: Some(MaterialRef::new(EditorAssetRef::new( "model", "material:body", "Body Material", ))), }], orphaned_assignments: Vec::new(), }, }; 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 migration_root = std::env::temp_dir().join(format!( "blacksite-canonical-upgrader-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let mut migration = migration_context(&migration_root); let upgraded = canonical_scene_document(&text, &mut migration).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_eq!(renderer.slots[0].material_slot_id.0, "slot:draw:body"); assert_eq!(renderer.materials.slots.len(), 1); assert_eq!( renderer.materials.slots[0] .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)); } #[test] fn primitive_legacy_material_becomes_one_deduplicated_project_asset_and_surface_assignment() { let root = std::env::temp_dir().join(format!( "blacksite-primitive-material-upgrader-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let primitive = Primitive::default(); let material = MaterialDesc { metallic: 0.65, roughness: 0.2, ..Default::default() }; let entity = format!( "(components: {{\"{COMPONENT_PRIMITIVE}\": {}, \"{COMPONENT_MATERIAL_DESC}\": {}}})", ron::to_string(&primitive).unwrap(), ron::to_string(&material).unwrap(), ); let text = format!("(schema_version: 4, resources: {{}}, entities: {{1: {entity}, 2: {entity}}})"); let mut migration = migration_context(&root); let upgraded = canonical_scene_document(&text, &mut migration).unwrap(); let document = SceneDocument::from_ron_text(&upgraded).unwrap(); assert_eq!(migration.material_writes.len(), 1); assert_eq!(migration.registry.records.len(), 1); let assigned = document .entities .iter() .map(|entity| { assert!(!entity .components .iter() .any(|component| component.type_name == COMPONENT_MATERIAL_DESC)); let primitive: Primitive = ron::from_str( &entity .components .iter() .find(|component| component.type_name == COMPONENT_PRIMITIVE) .unwrap() .ron, ) .unwrap(); assert_eq!(primitive.surface.id.0, shared::PRIMITIVE_SURFACE_SLOT_ID); primitive.surface.material.unwrap() }) .collect::>(); assert_eq!(assigned[0], assigned[1]); assert_eq!( assigned[0].0.asset_id, migration.registry.records[0].id.as_string() ); } #[test] fn skinned_slot_override_migrates_to_the_exact_saved_slot() { let root = std::env::temp_dir().join(format!( "blacksite-skinned-material-upgrader-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let slot_id = ComponentInstanceId::new("slot:body"); let renderer = SkinnedMeshRenderer { materials: MaterialSlotSet { slots: vec![MaterialSlot { id: slot_id.clone(), name: "Body".into(), ..Default::default() }], ..Default::default() }, ..Default::default() }; let overrides = MaterialOverride { slots: vec![MaterialSlotOverride { slot_id: slot_id.clone(), base_material: None, material: MaterialDesc { metallic: 1.0, ..Default::default() }, }], }; let text = format!( "(schema_version: 4, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_SKINNED_MESH_RENDERER}\": {}, \"{COMPONENT_MATERIAL_OVERRIDE}\": {}}})}})", ron::to_string(&renderer).unwrap(), ron::to_string(&overrides).unwrap(), ); let mut migration = migration_context(&root); let upgraded = canonical_scene_document(&text, &mut migration).unwrap(); let document = SceneDocument::from_ron_text(&upgraded).unwrap(); let components = &document.entities[0].components; let renderer: SkinnedMeshRenderer = ron::from_str( &components .iter() .find(|component| component.type_name == COMPONENT_SKINNED_MESH_RENDERER) .unwrap() .ron, ) .unwrap(); assert!(renderer .materials .slot(&slot_id) .unwrap() .material .is_some()); assert!(!components .iter() .any(|component| component.type_name == COMPONENT_MATERIAL_OVERRIDE)); assert_eq!(migration.material_writes.len(), 1); } #[test] fn material_collision_blocks_apply_without_writing_any_project_bytes() { let root = std::env::temp_dir().join(format!( "blacksite-material-collision-upgrader-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let material = MaterialDesc { metallic: 0.42, ..Default::default() }; let scene = root.join("assets/levels/collision.scn.ron"); fs::create_dir_all(scene.parent().unwrap()).unwrap(); let scene_bytes = format!( "(schema_version: 4, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_PRIMITIVE}\": {}, \"{COMPONENT_MATERIAL_DESC}\": {}}})}})", ron::to_string(&Primitive::default()).unwrap(), ron::to_string(&material).unwrap(), ); fs::write(&scene, &scene_bytes).unwrap(); let canonical = ron::ser::to_string(&material).unwrap(); let hash = blake3::hash(canonical.as_bytes()).to_hex().to_string(); let collision = root.join(format!( "assets/materials/migrated/legacy-{}.material.ron", &hash[..16] )); fs::create_dir_all(collision.parent().unwrap()).unwrap(); fs::write(&collision, "owned by another asset").unwrap(); let preview = upgrade_project(&root, false).unwrap(); assert_eq!(preview.warnings.len(), 1); let error = upgrade_project(&root, true).unwrap_err(); assert!(error.contains("blocked")); assert_eq!(fs::read_to_string(&scene).unwrap(), scene_bytes); assert_eq!( fs::read_to_string(&collision).unwrap(), "owned by another asset" ); assert!(!root.join("assets/.index/registry.ron").exists()); assert!(!root.join(".blacksite").exists()); fs::remove_dir_all(root).unwrap(); } }