Blacksite/crates/scene/src/lib.rs
Rbanh f29712d158
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Initial project import
2026-06-05 21:44:45 -04:00

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"));
}
}