//! Versioned scene text migrations. use shared::ActorKind; pub const DEFAULT_SUN_ILLUMINANCE_LUX: f32 = 100_000.0; /// Migrates v1 scene text to schema v2 (ActorKind backfill + directional lux fix). pub fn migrate_v1_to_v2(body: &str) -> String { let mut out = body.to_string(); out = migrate_directional_light_intensity(&out); out = backfill_actor_kinds(&out); out } fn backfill_actor_kinds(text: &str) -> String { let mut result = String::with_capacity(text.len() + 1024); let mut rest = text; while let Some(idx) = rest.find("components: {") { result.push_str(&rest[..idx]); let after = &rest[idx..]; let Some(close) = find_components_close(after) else { result.push_str(after); break; }; let block = &after[..close]; let tail = &after[close..]; if block.contains("\"shared::components::ActorKind\"") { result.push_str(block); } else if block.contains("\"shared::components::LevelObject\"") { let kind = infer_kind_from_block(block); result.push_str(&inject_actor_kind(block, kind)); } else { result.push_str(block); } rest = tail; } result.push_str(rest); result } fn find_components_close(after: &str) -> Option { let mut depth = 0; let bytes = after.as_bytes(); for (i, &b) in bytes.iter().enumerate() { match b { b'{' => depth += 1, b'}' => { depth -= 1; if depth == 0 { return Some(i + 1); } } _ => {} } } None } fn inject_actor_kind(block: &str, kind: &str) -> String { let Some(close) = block.rfind('}') else { return block.to_string(); }; format!( "{block_prefix}\"shared::components::ActorKind\": {kind},\n {close_brace}", block_prefix = &block[..close], close_brace = &block[close..] ) } fn infer_kind_from_block(block: &str) -> &'static str { if block.contains("\"shared::components::PlayerSpawn\"") { return actor_kind_ron(ActorKind::PlayerSpawn); } if block.contains("\"shared::components::WeaponSpawn\"") { return actor_kind_ron(ActorKind::WeaponSpawn); } if block.contains("\"shared::components::TriggerVolume\"") { return actor_kind_ron(ActorKind::TriggerVolume); } if block.contains("\"shared::components::PostProcessVolumeDesc\"") { return actor_kind_ron(ActorKind::PostProcessVolume); } if block.contains("\"shared::components::TeamSpawn\"") { return actor_kind_ron(ActorKind::TeamSpawn); } if block.contains("\"shared::components::ObjectiveMarker\"") { return actor_kind_ron(ActorKind::Objective); } if block.contains("\"shared::components::LightDesc\"") { return actor_kind_ron(ActorKind::Light); } if block.contains("\"shared::components::ModelRef\"") { return actor_kind_ron(ActorKind::ImportedModel); } if block.contains("\"shared::components::BrushDesc\"") { return actor_kind_ron(ActorKind::Brush); } if block.contains("\"shared::components::Primitive\"") || block.contains("\"shared::components::StaticMeshRenderer\"") { return actor_kind_ron(ActorKind::StaticMesh); } if block.contains("\"shared::components::PrefabRef\"") || block.contains("\"shared::components::PrefabInstance\"") { return actor_kind_ron(ActorKind::PrefabAnchor); } actor_kind_ron(ActorKind::Empty) } fn actor_kind_ron(kind: ActorKind) -> &'static str { match kind { ActorKind::Empty => "Empty", ActorKind::Brush => "Brush", ActorKind::StaticMesh => "StaticMesh", ActorKind::ImportedModel => "ImportedModel", ActorKind::Light => "Light", ActorKind::PrefabAnchor => "PrefabAnchor", ActorKind::PlayerSpawn => "PlayerSpawn", ActorKind::WeaponSpawn => "WeaponSpawn", ActorKind::TriggerVolume => "TriggerVolume", ActorKind::PostProcessVolume => "PostProcessVolume", ActorKind::TeamSpawn => "TeamSpawn", ActorKind::Objective => "Objective", } } fn migrate_directional_light_intensity(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(start) = rest.find("\"shared::components::LightDesc\"") { out.push_str(&rest[..start]); let block_start = start; let Some(rel_end) = find_light_desc_block_end(&rest[block_start..]) else { out.push_str(&rest[block_start..]); return out; }; let end = block_start + rel_end; let block = &rest[block_start..end]; let migrated = if block.contains("kind: Directional") { fix_directional_intensity(block) } else { block.to_string() }; out.push_str(&migrated); rest = &rest[end..]; } out.push_str(rest); out } fn find_light_desc_block_end(text: &str) -> Option { let open = text.find('(')?; let mut depth = 0; for (i, ch) in text[open..].char_indices() { match ch { '(' => depth += 1, ')' => { depth -= 1; if depth == 0 { return Some(open + i + 1); } } _ => {} } } None } fn fix_directional_intensity(block: &str) -> String { let Some(intensity_idx) = block.find("intensity:") else { return block.to_string(); }; let after = &block[intensity_idx + "intensity:".len()..]; let trimmed = after.trim_start(); let end = trimmed .find(|c: char| c == ',' || c == ')') .unwrap_or(trimmed.len()); let value_str = trimmed[..end].trim(); let Ok(value) = value_str.parse::() else { return block.to_string(); }; if value >= 1000.0 { return block.to_string(); } let replacement = format!("intensity: {}", DEFAULT_SUN_ILLUMINANCE_LUX); block.replace(&format!("intensity: {value_str}"), &replacement) } #[cfg(test)] mod tests { use super::*; #[test] fn backfills_actor_kind_for_primitive_entity() { let body = r#"( resources: {}, entities: { 1: ( components: { "shared::components::LevelObject": (), "shared::components::Primitive": ( shape: Box, size: (1.0, 1.0, 1.0), ), }, ), }, )"#; let migrated = migrate_v1_to_v2(body); assert!(migrated.contains("ActorKind")); assert!(migrated.contains("StaticMesh")); } #[test] fn backfills_actor_kind_for_brush_entity() { let body = r#"( resources: {}, entities: { 1: ( components: { "shared::components::LevelObject": (), "shared::components::BrushDesc": ( kind: Additive, faces: [], cast_shadows: true, receive_shadows: true, ), }, ), }, )"#; let migrated = migrate_v1_to_v2(body); assert!(migrated.contains("ActorKind")); assert!(migrated.contains("Brush")); } #[test] fn fixes_low_directional_lux() { let body = r#"( resources: {}, entities: { 1: ( components: { "shared::components::LightDesc": ( kind: Directional, color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), intensity: 10.0, range: 0.0, shadows: true, ), }, ), }, )"#; let migrated = migrate_v1_to_v2(body); assert!(migrated.contains("intensity: 100000")); } }