196 lines
6.2 KiB
Rust
196 lines
6.2 KiB
Rust
//! 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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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"));
|
|
}
|
|
}
|