1265 lines
46 KiB
Rust
1265 lines
46 KiB
Rust
//! 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<String>,
|
|
pub changes: Vec<ProjectUpgradeChange>,
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
struct PendingWrite {
|
|
path: PathBuf,
|
|
contents: String,
|
|
kind: &'static str,
|
|
existed: bool,
|
|
}
|
|
|
|
struct LegacyMaterialMigration<'a> {
|
|
root: &'a Path,
|
|
registry: AssetRegistryDocument,
|
|
material_writes: std::collections::BTreeMap<PathBuf, String>,
|
|
}
|
|
|
|
pub fn upgrade_project(root: &Path, apply: bool) -> Result<ProjectUpgradeReport, String> {
|
|
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::<Vec<_>>();
|
|
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::<settings::ProjectSettings>(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<String, String> {
|
|
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::<MaterialDesc>(&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::<MaterialOverride>(&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::<Vec<_>>();
|
|
|
|
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::<AuthoringComponentStates>(&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::<Vec<_>>();
|
|
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<MaterialRef, String> {
|
|
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<MaterialRef> {
|
|
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<String> {
|
|
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::<Vec<_>>();
|
|
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<String>,
|
|
#[serde(default)]
|
|
shader_ref: Option<shared::EditorAssetRef>,
|
|
#[serde(default)]
|
|
render_state: MaterialRenderState,
|
|
#[serde(default)]
|
|
provenance: Option<MaterialProvenance>,
|
|
material: MaterialDesc,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LegacyMaterialInstanceV1 {
|
|
label: String,
|
|
base: MaterialRef,
|
|
#[serde(default)]
|
|
parameters: Vec<MaterialParameter>,
|
|
#[serde(default)]
|
|
textures: Vec<shared::MaterialTextureBinding>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LegacyShaderSchemaV1 {
|
|
label: String,
|
|
kind: shared::MaterialShaderKind,
|
|
#[serde(default)]
|
|
wgsl_path: Option<String>,
|
|
#[serde(default)]
|
|
parameters: Vec<LegacyShaderInputV1>,
|
|
#[serde(default)]
|
|
default_values: Vec<MaterialParameter>,
|
|
#[serde(default)]
|
|
default_textures: Vec<shared::MaterialTextureBinding>,
|
|
}
|
|
|
|
#[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<AssetKind>,
|
|
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::<MaterialInstanceAsset>(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::<LegacyMaterialInstanceV1>(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::<MaterialAsset>(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::<LegacyMaterialAssetV1>(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::<ShaderSchemaAsset>(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::<LegacyShaderSchemaV1>(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::<std::collections::BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
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::<std::collections::BTreeSet<_>>();
|
|
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::<Vec<_>>();
|
|
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::<String>();
|
|
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::<Vec<_>>();
|
|
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();
|
|
}
|
|
}
|