//! Scene file schema versioning for `assets/levels/*.scn.ron`. //! //! Bevy dynamic scenes are stored as RON tuples. On save the editor stamps //! `schema_version` as the first root field; loaders strip it before deserializing. mod migrate; use std::path::Path; use serde::{Deserialize, Serialize}; pub use migrate::{migrate_v1_to_v2, DEFAULT_SUN_ILLUMINANCE_LUX}; pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SceneSchemaHeader { pub schema_version: u32, } impl Default for SceneSchemaHeader { fn default() -> Self { Self { schema_version: CURRENT_SCENE_SCHEMA_VERSION, } } } fn read_schema_version(text: &str) -> u32 { let trimmed = text.trim(); if !trimmed.starts_with("(schema_version:") { return 1; } let inner = trimmed .strip_prefix('(') .and_then(|s| s.strip_prefix("schema_version:")) .unwrap_or(""); let version_str = inner.trim().split(',').next().unwrap_or("1").trim(); version_str.parse().unwrap_or(1) } /// Inserts `schema_version` as the first field of a Bevy dynamic-scene RON root tuple. pub fn stamp_schema_version(text: &str) -> Result { let trimmed = text.trim(); if trimmed.starts_with("(schema_version:") { let version = read_schema_version(trimmed); if version == CURRENT_SCENE_SCHEMA_VERSION { return Ok(trimmed.to_string()); } let body = strip_schema_version(trimmed)?; return stamp_schema_version(&body); } if !trimmed.starts_with('(') || !trimmed.ends_with(')') { return Err("expected scene RON root tuple".into()); } Ok(format!( "(schema_version: {},{}", CURRENT_SCENE_SCHEMA_VERSION, &trimmed[1..] )) } /// Applies versioned migrations and stamps the current schema header. pub fn migrate_scene_text(text: &str) -> Result { let trimmed = text.trim(); let version = read_schema_version(trimmed); let body = if trimmed.starts_with("(schema_version:") { strip_schema_version(trimmed)? } else { trimmed.to_string() }; let migrated_body = match version { 0 | 1 => migrate_v1_to_v2(&body), 2 => body, _ => body, }; stamp_schema_version(&migrated_body) } /// Removes the schema header so Bevy can deserialize the dynamic scene body. pub fn strip_schema_version(text: &str) -> Result { let trimmed = text.trim(); if !trimmed.starts_with("(schema_version:") { return Ok(trimmed.to_string()); } let inner = trimmed .strip_prefix('(') .ok_or_else(|| "invalid schema wrapper".to_string())?; let after_key = inner .strip_prefix("schema_version:") .ok_or_else(|| "invalid schema_version prefix".to_string())?; let comma = after_key .find(',') .ok_or_else(|| "schema_version tuple missing body".to_string())?; Ok(format!("({}", after_key[comma + 1..].trim_start())) } /// Hydrated/runtime component type names that must not appear in saved `.scn.ron` files. pub const FORBIDDEN_SAVED_COMPONENT_MARKERS: &[&str] = &[ "bevy_pbr::PointLight", "bevy_pbr::SpotLight", "bevy_pbr::DirectionalLight", "bevy_mesh::Mesh3d", "bevy_mesh::MeshMaterial3d", "avian3d::RigidBody", "avian3d::Collider", "bevy_scene::SceneRoot", "bevy_render::visibility::ComputedVisibility", ]; /// Ensures serialized scene text does not contain hydrated ECS component entries. pub fn validate_scene_authoring_only(text: &str) -> Result<(), String> { for marker in FORBIDDEN_SAVED_COMPONENT_MARKERS { if text.contains(marker) { return Err(format!( "scene file contains hydrated component marker `{marker}` (save allowlist violation)" )); } } Ok(()) } pub fn validate_level_text(text: &str) -> Result<(), String> { if text.trim().is_empty() { return Err("empty scene file".into()); } let migrated = migrate_scene_text(text)?; strip_schema_version(&migrated)?; validate_scene_authoring_only(&migrated)?; Ok(()) } pub fn validate_level_file(path: &Path) -> Result<(), String> { let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?; validate_level_text(&text) } #[cfg(test)] mod tests { use super::*; #[test] fn stamp_and_strip_round_trip() { let body = "(resources: {}, entities: {})"; let stamped = stamp_schema_version(body).unwrap(); assert!(stamped.starts_with("(schema_version: 2,")); let stripped = strip_schema_version(&stamped).unwrap(); assert_eq!(stripped, body); } #[test] fn legacy_scene_migrates_to_v2() { let legacy = "(resources: {}, entities: {4294967133: ()})"; let migrated = migrate_scene_text(legacy).unwrap(); assert!(migrated.contains("schema_version: 2")); } #[test] #[ignore = "run manually to rewrite assets/levels/editor_scene.scn.ron"] fn migrate_editor_scene_on_disk() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../assets/levels/editor_scene.scn.ron"); let text = std::fs::read_to_string(&path).expect("read editor scene"); let migrated = migrate_scene_text(&text).expect("migrate"); std::fs::write(&path, migrated).expect("write migrated scene"); } #[test] fn repo_editor_scene_has_no_hydrated_components() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../assets/levels/editor_scene.scn.ron"); let text = std::fs::read_to_string(&path).expect("read editor scene"); validate_scene_authoring_only(&text).expect("editor scene must be authoring-only"); } #[test] fn v1_scene_gains_actor_kind_on_migrate() { let v1 = "(schema_version: 1,(resources: {}, entities: { 1: ( components: { \"shared::components::LevelObject\": (), \"shared::components::Primitive\": ( shape: Box, size: (1.0, 1.0, 1.0), ), }, ), }))"; let migrated = migrate_scene_text(&v1).unwrap(); assert!(migrated.contains("ActorKind")); assert!(migrated.contains("StaticMesh")); } }