diff --git a/crates/skate-data/examples/map_decode.rs b/crates/skate-data/examples/map_decode.rs new file mode 100644 index 00000000..7c5dda27 --- /dev/null +++ b/crates/skate-data/examples/map_decode.rs @@ -0,0 +1,21 @@ +//! Data-only package timing tool. Does not initialize the engine or simulate. +use std::{path::Path, time::Instant}; +fn main() { + let compare = std::env::args().any(|arg| arg == "--compare"); + for path in std::env::args().skip(1).filter(|arg| arg != "--compare") { + for pass in 0..if compare { 1 } else { 3 } { + let start = Instant::now(); + let bytes = std::fs::read(Path::new(&path)).expect("read map"); + let read = start.elapsed(); + let start = Instant::now(); + let map = skate_data::skate_map::SkateMap::parse(&bytes).expect("decode map"); + println!("DATA_DECODE name={:?} pass={} read_ms={} parse_ms={} textures={} vertices={}", + map.name, pass, read.as_millis(), start.elapsed().as_millis(), map.textures.len(), map.geometry.vertices.len()); + if compare { + let serial = skate_data::skate_map::SkateMap::parse_with_decode_workers(&bytes, 1).expect("serial decode"); + assert!(map == serial, "parallel decoding changed package contents"); + println!("DATA_EQUAL name={:?} all_fields=true", map.name); + } + } + } +} diff --git a/crates/skate-data/src/skate_map.rs b/crates/skate-data/src/skate_map.rs index 6c229971..56ccca9b 100644 --- a/crates/skate-data/src/skate_map.rs +++ b/crates/skate-data/src/skate_map.rs @@ -1,9 +1,10 @@ //! Little-endian SKATE01..14 reader. Layout follows the supplied //! SK8R15/Source/tools/blender_owned_map/SKATE_FORMAT.md and //! owned/world/src/owned_map_package.cpp. No geometry or physics is inferred. -use std::{io::Read, path::Path}; +use std::{io::Read, path::Path, time::Instant}; +mod texture_decode; -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct SkateMap { pub version: u8, pub name: String, @@ -20,7 +21,7 @@ pub struct SkateMap { pub routes: Vec, pub extensions: Vec, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Material { pub name: String, pub flags: u32, @@ -41,7 +42,7 @@ pub struct Material { /// Complete v12+ retail definition bytes; distinct from portable PBR fields. pub retail_definition: Option>, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Texture { pub name: String, pub width: u32, @@ -49,7 +50,7 @@ pub struct Texture { pub color_space: u32, pub rgba: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Vertex { pub position: [f32; 3], pub normal: [f32; 3], @@ -59,27 +60,27 @@ pub struct Vertex { pub decal_uv: Option<[f32; 2]>, pub tangent_frame: Option<[u8; 4]>, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Collision { pub points: [[f32; 3]; 3], pub surface: u32, pub material: u32, pub native_edges: Option<[u8; 3]>, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Geometry { pub vertices: Vec, pub indices: Vec, pub collision: Vec, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Rail { pub name: String, pub closed: bool, pub points: Vec<[f32; 3]>, pub native: Option>, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Door { pub name: String, /// Hinge position/axis, closed width/depth axes, local min/max. @@ -93,7 +94,7 @@ pub struct Door { pub surface: u32, pub geometry: Geometry, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Light { pub name: String, pub kind: u32, @@ -106,14 +107,14 @@ pub struct Light { pub inner_cos: f32, pub outer_cos: f32, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Route { pub rail: Rail, pub skaters: u32, pub speed: f32, pub spacing: f32, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Extension { pub tag: [u8; 4], pub schema: u32, @@ -204,43 +205,17 @@ impl<'a> Reader<'a> { self.check_count(n, 12)?; (0..n).map(|_| self.floats()).collect() } - fn stored(&mut self, expected: usize) -> Result, String> { + fn stored_block(&mut self, expected: usize) -> Result, String> { if expected > 2_147_483_648 { return Err("SKATE decoded block exceeds 2 GiB reader limit".into()); } let method = self.u()?; let n = self.u()? as usize; let bytes = self.take(n)?; - let decoded = match method { - 0 => bytes.to_vec(), - 1 => { - let mut result = Vec::new(); - let mut decoder = flate2::read::ZlibDecoder::new(bytes); - decoder - .by_ref() - .take(expected as u64 + 1) - .read_to_end(&mut result) - .map_err(|e| format!("SKATE DEFLATE: {e}"))?; - if decoder.total_in() as usize != bytes.len() { - return Err("SKATE compressed block has unused input".into()); - } - result - } - 2 => { - let mut result = Vec::new(); - zstd::stream::read::Decoder::new(bytes) - .map_err(|e| e.to_string())? - .take(expected as u64 + 1) - .read_to_end(&mut result) - .map_err(|e| format!("SKATE Zstandard: {e}"))?; - result - } - _ => return Err(format!("Unsupported SKATE storage method {method}")), - }; - if decoded.len() != expected { - return Err("SKATE decoded block size mismatch".into()); - } - Ok(decoded) + Ok(StoredBlock { expected, method, bytes }) + } + fn stored(&mut self, expected: usize) -> Result, String> { + self.stored_block(expected)?.decode() } fn geometry( &mut self, @@ -337,12 +312,63 @@ impl<'a> Reader<'a> { }) } } +/// Borrow compressed payloads while scanning; decompression does not mutate +/// the package or depend on any other texture. +struct StoredBlock<'a> { expected: usize, method: u32, bytes: &'a [u8] } +impl StoredBlock<'_> { + fn decode(&self) -> Result, String> { + let Self { expected, method, bytes } = *self; + let decoded = match method { + 0 => bytes.to_vec(), + 1 => { + let mut result = Vec::with_capacity(expected.min(8 * 1024 * 1024)); + let mut decoder = flate2::read::ZlibDecoder::new(bytes); + decoder + .by_ref() + .take(expected as u64 + 1) + .read_to_end(&mut result) + .map_err(|e| format!("SKATE DEFLATE: {e}"))?; + if decoder.total_in() as usize != bytes.len() { + return Err("SKATE compressed block has unused input".into()); + } + result + } + 2 => { + let mut result = Vec::with_capacity(expected.min(8 * 1024 * 1024)); + zstd::stream::read::Decoder::new(bytes) + .map_err(|e| e.to_string())? + .take(expected as u64 + 1) + .read_to_end(&mut result) + .map_err(|e| format!("SKATE Zstandard: {e}"))?; + result + } + _ => return Err(format!("Unsupported SKATE storage method {method}")), + }; + if decoded.len() != expected { + return Err("SKATE decoded block size mismatch".into()); + } + Ok(decoded) + } +} impl SkateMap { pub fn load(path: &Path) -> Result { + let started = Instant::now(); let data = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - Self::parse(&data).map_err(|e| format!("{}: {e}", path.display())) + let disk = started.elapsed(); + let parse_started = Instant::now(); + let map = Self::parse(&data).map_err(|e| format!("{}: {e}", path.display()))?; + eprintln!("MAP_READ_TIMING name={:?} disk_ms={} parse_ms={} file_bytes={}", + map.name, disk.as_millis(), parse_started.elapsed().as_millis(), data.len()); + Ok(map) } pub fn parse(data: &[u8]) -> Result { + let workers = std::thread::available_parallelism().map_or(1, |n| n.get().min(4)); + Self::parse_with_decode_workers(data, workers) + } + /// Data tools can request one worker for reproducible serial comparisons. + /// The decoder caps concurrency at eight and skips threading for small maps. + pub fn parse_with_decode_workers(data: &[u8], workers: usize) -> Result { + let parse_started = Instant::now(); let mut r = Reader { bytes: data, at: 0 }; let magic = r.take(8)?; if &magic[..5] != b"SKATE" @@ -470,8 +496,10 @@ impl SkateMap { } materials.push(m); } + let texture_started = Instant::now(); r.check_count(counts[1], 20)?; let mut textures = Vec::new(); + let mut texture_blocks = Vec::new(); for _ in 0..counts[1] { let name = r.string()?; let width = r.u()?; @@ -481,23 +509,27 @@ impl SkateMap { return Err("Invalid SKATE embedded texture".into()); } let expected = width as usize * height as usize * 4; - let rgba = if version >= 9 { - r.stored(expected)? + let block = if version >= 9 { + r.stored_block(expected)? } else { let bytes = r.u()? as usize; if bytes != expected { return Err("Invalid SKATE embedded texture size".into()); } - r.take(bytes)?.to_vec() + StoredBlock { expected, method: 0, bytes: r.take(bytes)? } }; + texture_blocks.push(block); textures.push(Texture { name, width, height, color_space, - rgba, + rgba: Vec::new(), }); } + let texture_workers = texture_decode::decode(&mut textures, &texture_blocks, workers)?; + let texture_time = texture_started.elapsed(); + let geometry_started = Instant::now(); let geometry_counts = [counts[2], counts[3], counts[4]]; let geometry = if version >= 9 { let mut bytes = r.stored(counts[2] as usize * if version >= 12 { 56 } else { 44 })?; @@ -511,6 +543,7 @@ impl SkateMap { } else { r.geometry(geometry_counts, materials.len(), version)? }; + let geometry_time = geometry_started.elapsed(); if materials.is_empty() || geometry.vertices.is_empty() || geometry.indices.is_empty() @@ -622,6 +655,7 @@ impl SkateMap { spacing, }); } + let extension_started = Instant::now(); let mut extensions = Vec::new(); if version >= 12 { let n = r.u()?; @@ -646,6 +680,9 @@ impl SkateMap { data.len() - r.at )); } + eprintln!("MAP_DECODE_TIMING name={:?} textures_ms={} texture_workers={} geometry_ms={} extensions_ms={} total_ms={}", + name, texture_time.as_millis(), texture_workers, geometry_time.as_millis(), + extension_started.elapsed().as_millis(), parse_started.elapsed().as_millis()); Ok(Self { version, name, diff --git a/crates/skate-data/src/skate_map/texture_decode.rs b/crates/skate-data/src/skate_map/texture_decode.rs new file mode 100644 index 00000000..ccdab039 --- /dev/null +++ b/crates/skate-data/src/skate_map/texture_decode.rs @@ -0,0 +1,93 @@ +//! Bounded parallel decompression with deterministic texture indices. +use super::{StoredBlock, Texture}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub(super) fn decode(textures: &mut [Texture], blocks: &[StoredBlock<'_>], workers: usize) -> Result { + let bytes = blocks.iter().fold(0usize, |sum, block| sum.saturating_add(block.expected)); + let workers = if bytes < 1024 * 1024 { 1 } else { workers.clamp(1, 8).min(blocks.len().max(1)) }; + if workers == 1 { + for (texture, block) in textures.iter_mut().zip(blocks) { texture.rgba = block.decode()?; } + return Ok(1); + } + // Dynamic assignment balances many tiny textures against a few large ones. + // Compressed slices borrow the original file; no second package copy exists. + let next = AtomicUsize::new(0); + let decode_batch = || -> Result)>, String> { + let mut decoded = Vec::new(); + loop { + let index = next.fetch_add(1, Ordering::Relaxed); + let Some(block) = blocks.get(index) else { break; }; + decoded.push((index, block.decode().map_err(|e| format!("Texture {index}: {e}"))?)); + } + Ok(decoded) + }; + let decoded = std::thread::scope(|scope| -> Result<_, String> { + let mut jobs = Vec::new(); + for _ in 1..workers { + jobs.push(std::thread::Builder::new().name("map-texture-decode".into()) + .spawn_scoped(scope, &decode_batch).map_err(|e| format!("Texture worker: {e}"))?); + } + let local = decode_batch(); + // Join every worker before returning either success or an error. + let batches: Vec<_> = jobs.into_iter().map(|job| job.join() + .unwrap_or_else(|_| Err("Texture decoder failed unexpectedly".into()))).collect(); + let mut decoded = local?; + for batch in batches { decoded.extend(batch?); } + Ok(decoded) + })?; + for (index, rgba) in decoded { textures[index].rgba = rgba; } + Ok(workers) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn mixed_storage_keeps_order_and_rejects_corruption_in_parallel() { + let raw: Vec> = (0..16).map(|i| vec![i as u8; (i + 1) * 32 * 1024]).collect(); + let mut encoded: Vec> = raw.iter().enumerate().map(|(i, bytes)| match i % 3 { + 0 => bytes.clone(), + 1 => { + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + _ => zstd::stream::encode_all(bytes.as_slice(), 1).unwrap(), + }).collect(); + let make_textures = || (0..raw.len()).map(|i| Texture { + name: format!("texture-{i}"), width: 1, height: 1, color_space: 0, rgba: Vec::new(), + }).collect::>(); + { + let blocks: Vec<_> = encoded.iter().enumerate().map(|(i, bytes)| StoredBlock { + bytes, method: (i % 3) as u32, expected: raw[i].len(), + }).collect(); + let mut serial = make_textures(); + decode(&mut serial, &blocks, 1).unwrap(); + for workers in [2, 4, usize::MAX] { + let mut parallel = make_textures(); + assert_eq!(decode(&mut parallel, &blocks, workers).unwrap(), workers.min(8)); + assert_eq!(serial, parallel); + for (texture, bytes) in parallel.iter().zip(&raw) { assert_eq!(&texture.rgba, bytes); } + } + } + encoded[5].truncate(3); + let blocks: Vec<_> = encoded.iter().enumerate().map(|(i, bytes)| StoredBlock { + bytes, method: (i % 3) as u32, expected: raw[i].len(), + }).collect(); + assert!(decode(&mut make_textures(), &blocks, 4).is_err()); + } + + #[test] + fn stored_block_retains_size_and_deflate_tail_validation() { + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(&[7; 64]).unwrap(); + let mut bytes = encoder.finish().unwrap(); + assert!(StoredBlock { expected: 63, method: 1, bytes: &bytes }.decode().is_err()); + bytes.push(42); + assert!(StoredBlock { expected: 64, method: 1, bytes: &bytes }.decode().is_err()); + assert!(StoredBlock { expected: 1, method: 0, bytes: &[] }.decode().is_err()); + assert!(StoredBlock { expected: 0, method: 99, bytes: &[] }.decode().is_err()); + } +} diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 7f970160..598aab81 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -101,6 +101,7 @@ pub(crate) fn build( crate::grind_world::GrindGeometryPlugin, camera::CameraPlugin, crate::graphics_menu::GraphicsMenuPlugin, + crate::map_transition::MapTransitionPlugin, crate::render_capacity::RenderCapacityPlugin, verification::VerificationPlugin, crate::performance::PerformancePlugin, diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index 2e6b5d9f..651d0bd8 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -68,3 +68,43 @@ fn present(mut runtime: ResMut, windows: Query<&Window>, camera.is_active = true; } } + +/// Keep the presentation camera/target/settings while replacing world-specific +/// postprocessing. A procedural scene must not inherit native HDR/tone state. +pub(crate) fn set_world_environment(world: &mut World, retail: bool) { + let cameras: Vec<_> = world.query_filtered::>().iter(world).collect(); + for id in cameras { + let mut camera = world.entity_mut(id); + if retail { + camera.insert((bevy::render::view::Hdr, bevy::core_pipeline::tonemapping::Tonemapping::None, + crate::retail_render::RetailTone::default())); + } else { + camera.remove::<(bevy::render::view::Hdr, crate::retail_render::RetailTone)>(); + camera.insert(bevy::core_pipeline::tonemapping::Tonemapping::default()); + } + camera.get_mut::().unwrap().is_active = false; + *camera.get_mut::().unwrap() = Transform::default(); + } +} + +#[cfg(test)] +mod environment_tests { + use super::*; + #[test] + fn native_procedural_switch_clears_tone_hdr_and_retains_camera_settings() { + let mut world = World::new(); + let id = world.spawn((GameplayCamera, Camera3d::default(), Msaa::Sample4, + bevy::camera::RenderTarget::default())).id(); + for retail in [true, false, true, false] { + set_world_environment(&mut world, retail); + let entity = world.entity(id); + assert_eq!(entity.contains::(), retail); + assert_eq!(entity.contains::(), retail); + assert_eq!(*entity.get::().unwrap(), Msaa::Sample4); + assert!(!entity.get::().unwrap().is_active); + assert_eq!(*entity.get::().unwrap(), + if retail { bevy::core_pipeline::tonemapping::Tonemapping::None } + else { bevy::core_pipeline::tonemapping::Tonemapping::default() }); + } + } +} diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs index 3c56c2a6..c0b175b0 100644 --- a/crates/skate-game/src/config.rs +++ b/crates/skate-game/src/config.rs @@ -9,6 +9,7 @@ pub(crate) struct Config { pub map_path: Option, pub difficulty: crate::difficulty::Difficulty, pub check_assets: bool, + pub start_paused: bool, } impl Config { @@ -20,6 +21,7 @@ impl Config { map_path: None, difficulty: crate::difficulty::Difficulty::default(), check_assets: false, + start_paused: false, }; let mut difficulty_override = None; let mut explicit_map = false; @@ -37,6 +39,7 @@ impl Config { } Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; } Some("--check-assets") => config.check_assets = true, + Some("--start-paused") => config.start_paused = true, Some("--difficulty") => { let value = args.next().ok_or("--difficulty requires easy, normal or hardcore")?; difficulty_override = Some(crate::difficulty::Difficulty::parse(&value.to_string_lossy())?); @@ -50,7 +53,7 @@ impl Config { } _ => { return Err(format!( - "Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png] [--check-assets]" + "Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png] [--check-assets] [--start-paused]" )); } } diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index a3d77f99..ab9356cc 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -85,6 +85,12 @@ pub(crate) struct Menu { maps: Vec, selected_map: usize, } +impl Menu { + pub(crate) fn transition_finished(&mut self, status: String, resume: bool) { + self.status = status; + self.open = !resume; + } +} pub(crate) fn gameplay_active(menu: Option>) -> bool { menu.is_none_or(|m| !m.open) } @@ -101,12 +107,16 @@ struct MenuLabel(usize); #[derive(Component)] struct StatusLabel; +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct MenuInput; + pub(crate) struct GraphicsMenuPlugin; impl Plugin for GraphicsMenuPlugin { fn build(&self, app: &mut App) { app.insert_resource(FramePacer(Instant::now())) .add_systems(PostStartup, setup) - .add_systems(Update, (interact, apply, labels).chain()) + .add_systems(PreUpdate, interact.in_set(MenuInput).after(bevy::input::InputSystems)) + .add_systems(Update, (apply, labels).chain()) .add_systems(Last, pace); } } @@ -117,6 +127,7 @@ fn setup( mut window: Single<&mut Window, With>, cameras: Query>, adapter: Res, + mut time: ResMut>, ) { let path = config .asset_root @@ -211,8 +222,9 @@ fn setup( commands.insert_resource(SceneTarget(target)); let maps = crate::map_library::discover(&config.asset_root); let selected_map = maps.iter().position(|m| m.path.as_ref() == config.map_path.as_ref()).unwrap_or(0); + if config.start_paused { time.pause(); } commands.insert_resource(Menu { - open: false, + open: config.start_paused, selected: 0, settings, path, @@ -236,7 +248,8 @@ fn cycle(values: &[T], value: T, direction: i32) -> T { values[(index + direction).rem_euclid(values.len() as i32) as usize] } fn interact( - config: Res, + mut config: ResMut, + mut transition: ResMut, mut physics: ResMut, keys: Res>, mut menu: ResMut, @@ -244,6 +257,11 @@ fn interact( buttons: Query<(&Interaction, &MenuRow), Changed>, mut exit: MessageWriter, ) { + if transition.busy() { + menu.open = true; + time.pause(); + return; + } if keys.just_pressed(KeyCode::Escape) { menu.open = !menu.open; } @@ -288,6 +306,7 @@ fn interact( 5 => { menu.difficulty = cycle(&Difficulty::ALL, menu.difficulty, direction); physics.set_difficulty(menu.difficulty); + config.difficulty = menu.difficulty; menu.status = match menu.difficulty.save(&config.asset_root) { Ok(()) => "Difficulty saved".into(), Err(e) => format!("Applied, but could not save: {e}"), @@ -299,10 +318,8 @@ fn interact( menu.status = "Choose Load map to switch".into(); } 7 => { - match crate::map_library::switch(&config.asset_root, &menu.maps[menu.selected_map]) { - Ok(()) => { exit.write(AppExit::Success); } - Err(e) => menu.status = e, - } + transition.request(menu.maps[menu.selected_map].clone()); + menu.status = "Loading map...".into(); } 8 => menu.open = false, 9 => { @@ -380,6 +397,8 @@ fn apply( } fn labels( menu: Res, + transition: Res, + time: Res>, window: Single<&Window, With>, mut root: Single<&mut Node, With>, mut labels: Query<(&MenuLabel, &mut Text), Without>, @@ -422,12 +441,14 @@ fn labels( 4 => format!("Occlusion culling {}", if s.occlusion { "On" } else { "Off" }), 5 => format!("Difficulty {}", menu.difficulty.label()), 6 => format!("Map {}", menu.maps[menu.selected_map].label), - 7 => "Load map (restarts session)".into(), + 7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() }, 8 => "Resume".into(), _ => "Quit game".into(), }; } - ***status = menu.status.clone(); + ***status = if transition.busy() { + format!("{} {}\nGameplay is paused. Please wait.", ["|", "/", "-", "\\"][(time.elapsed_secs() * 4.) as usize % 4], transition.label()) + } else { menu.status.clone() }; for (row, interaction, mut color) in &mut buttons { color.0 = if row.0 == menu.selected || *interaction == Interaction::Hovered { Color::srgb(0.10, 0.30, 0.34) @@ -462,6 +483,7 @@ mod tests { .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), + maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0, }) .add_systems(Update, apply); app.world_mut().spawn((Window::default(), PrimaryWindow)); diff --git a/crates/skate-game/src/grind_world.rs b/crates/skate-game/src/grind_world.rs index ae97a66a..a985d24e 100644 --- a/crates/skate-game/src/grind_world.rs +++ b/crates/skate-game/src/grind_world.rs @@ -15,11 +15,7 @@ pub(crate) struct GrindGeometryPlugin; impl Plugin for GrindGeometryPlugin { fn build(&self, app: &mut App) { let test_world = app.world().resource::().map.is_none(); - let geometry = if test_world { - GrindGeometry { rails: rails().to_vec(), native_blob: spline_blob() } - } else { - GrindGeometry { rails: Vec::new(), native_blob: Vec::new() } - }; + let geometry = GrindGeometry::for_world(test_world); if test_world { info!("GRIND_GEOMETRY rails={} bytes={} native_acquisition=paired_trucks_50_50", geometry.rails.len(), geometry.native_blob.len()); @@ -32,6 +28,13 @@ impl Plugin for GrindGeometryPlugin { } } +impl GrindGeometry { + pub(crate) fn for_world(test_world: bool) -> Self { + if test_world { Self { rails: rails().to_vec(), native_blob: spline_blob() } } + else { Self { rails: Vec::new(), native_blob: Vec::new() } } + } +} + #[derive(Clone, Copy, Debug)] pub(crate) struct Rail { pub id: u64, diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 8d93deda..334f2f5a 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -6,6 +6,8 @@ mod camera; mod config; mod setup; mod map_library; +mod map_render; +mod map_transition; mod difficulty; mod graph_host; mod graph_runtime; diff --git a/crates/skate-game/src/map_library.rs b/crates/skate-game/src/map_library.rs index fcfc03c4..e38275e1 100644 --- a/crates/skate-game/src/map_library.rs +++ b/crates/skate-game/src/map_library.rs @@ -1,7 +1,7 @@ -//! Installed maps are selected by path; switching restarts the whole simulation. -//! This prevents old collision bodies, graph state and GPU assets surviving a change. -use std::{path::{Path, PathBuf}, process::Command}; +//! Installed map discovery and the default pointer, shared by startup and the menu. +use std::path::{Path, PathBuf}; +#[derive(Clone, Debug)] pub(crate) struct Entry { pub label: String, pub path: Option, @@ -17,7 +17,7 @@ pub(crate) fn discover(assets: &Path) -> Vec { if path.is_file() && path.extension().is_some_and(|e| e.eq_ignore_ascii_case("skate")) { maps.push(Entry { label: path.file_stem().unwrap().to_string_lossy().replace('_', " "), - path: Some(path), + path: Some(path.canonicalize().unwrap_or(path)), }); } } @@ -37,7 +37,8 @@ pub(crate) fn default_map(assets: &Path) -> Result, String> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(format!("{}: {e}", pointer.display())), }; - let relative: String = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + let relative: Option = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + let Some(relative) = relative else { return Ok(None); }; let relative = Path::new(&relative); if relative.is_absolute() || relative.components().any(|c| !matches!(c, std::path::Component::Normal(_))) { return Err("Invalid installed default map path".into()); @@ -45,18 +46,34 @@ pub(crate) fn default_map(assets: &Path) -> Result, String> { Ok(Some(assets.parent().unwrap_or(assets).join(relative))) } -pub(crate) fn switch(assets: &Path, entry: &Entry) -> Result<(), String> { - if let Some(path) = &entry.path { - let map = skate_data::skate_map::SkateMap::load(path)?; - crate::skate_world::validate_runtime(&map)?; +/// Save only a successfully activated map. Null explicitly selects the test world. +pub(crate) fn save_default(assets: &Path, map: Option<&Path>) -> Result<(), String> { + let root = assets.parent().unwrap_or(assets); + let relative = map.map(|path| path.strip_prefix(root) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .map_err(|_| "Map is outside this installation; default map unchanged".to_string())).transpose()?; + let directory = root.join("settings"); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let temporary = directory.join("default-map.json.tmp"); + std::fs::write(&temporary, serde_json::to_vec_pretty(&relative).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + std::fs::rename(temporary, directory.join("default-map.json")).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn default_pointer_round_trips_map_and_explicit_test_world() { + let root = std::env::temp_dir().join(format!("skate-map-pointer-{}", std::process::id())); + let assets = root.join("assets"); + let map = root.join("maps/example.skate"); + save_default(&assets, Some(&map)).unwrap(); + assert_eq!(default_map(&assets).unwrap(), Some(map)); + save_default(&assets, None).unwrap(); + assert_eq!(default_map(&assets).unwrap(), None); + std::fs::write(root.join("settings/default-map.json"), br#""../outside.skate""#).unwrap(); + assert!(default_map(&assets).is_err()); + std::fs::remove_dir_all(root).unwrap(); } - let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?); - command.arg("--assets").arg(assets); - if let Some(path) = &entry.path { - command.arg("--map").arg(path); - } else { - command.arg("--test-world"); - } - command.spawn().map_err(|e| format!("Could not open map: {e}"))?; - Ok(()) } diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs new file mode 100644 index 00000000..d1422c62 --- /dev/null +++ b/crates/skate-game/src/map_render.rs @@ -0,0 +1,192 @@ +//! CPU scene construction can run off-thread using the live asset allocators. +//! Only publishing assets/entities and retiring the previous scene touch the ECS. +use bevy::{asset::{AssetHandleProvider, AssetId}, ecs::world::CommandQueue, prelude::*}; +use crate::retail_render::{RetailSkyMaterial, RetailWorldMaterial}; + +#[derive(Component)] +pub(crate) struct MapEntity; + +pub(crate) trait AssetSink { + fn add(&mut self, asset: A) -> Handle; +} +impl AssetSink for Assets { + fn add(&mut self, asset: A) -> Handle { Assets::add(self, asset) } +} + +pub(crate) struct StagedAssets { + provider: AssetHandleProvider, + values: Vec<(Handle, A)>, +} +impl StagedAssets { + fn new(world: &World) -> Self { + Self { provider: world.resource::>().get_handle_provider(), values: Vec::new() } + } + fn publish(self, world: &mut World) -> Vec> { + let mut assets = world.resource_mut::>(); + self.values.into_iter().map(|(handle, asset)| { + let id = handle.id(); + // Reserved from this Assets' allocator; the staged strong handle is + // alive throughout preparation, so its generation cannot be reused. + assets.insert(id, asset).expect("reserved map asset generation"); + id + }).collect() + } +} +impl AssetSink for StagedAssets { + fn add(&mut self, asset: A) -> Handle { + let handle = self.provider.reserve_handle().typed::(); + self.values.push((handle.clone(), asset)); + handle + } +} + +/// Deferred spawns allocate their ECS identities only when the scene commits. +#[derive(Default)] +pub(crate) struct SceneCommands(CommandQueue); +pub(crate) struct SceneEntity<'a> { + commands: &'a mut CommandQueue, + id: std::sync::Arc>, +} +impl SceneCommands { + pub fn spawn(&mut self, bundle: B) -> SceneEntity<'_> { + let id = std::sync::Arc::new(std::sync::OnceLock::new()); + let spawned = id.clone(); + self.0.push(move |world: &mut World| { + spawned.set(world.spawn((MapEntity, bundle)).id()).unwrap(); + }); + SceneEntity { commands: &mut self.0, id } + } + pub fn insert_resource(&mut self, resource: R) { + self.0.push(move |world: &mut World| { world.insert_resource(resource); }); + } +} +impl SceneEntity<'_> { + pub fn insert(&mut self, bundle: B) { + let id = self.id.clone(); + self.commands.push(move |world: &mut World| { + world.entity_mut(*id.get().expect("scene spawn precedes insert")).insert(bundle); + }); + } +} + +pub(crate) struct PreparedScene { + commands: SceneCommands, + meshes: StagedAssets, + materials: StagedAssets, + retail: StagedAssets, + sky: StagedAssets, + images: StagedAssets, +} +impl PreparedScene { + pub fn new(world: &World) -> Self { + Self { commands: Default::default(), meshes: StagedAssets::new(world), + materials: StagedAssets::new(world), retail: StagedAssets::new(world), + sky: StagedAssets::new(world), images: StagedAssets::new(world) } + } + pub fn prepare(&mut self, map: Option<&skate_data::skate_map::SkateMap>, root: &std::path::Path) { + // Every scene starts with the same environment defaults. Map-specific + // resources below overwrite these, including after a native scene. + self.commands.insert_resource(ClearColor(Color::srgb(0.065, 0.08, 0.10))); + self.commands.insert_resource(GlobalAmbientLight { + color: Color::WHITE, brightness: 350., ..default() + }); + if let Some(map) = map { + crate::skate_world::spawn(map, &mut self.commands, &mut self.meshes, + &mut self.materials, &mut self.retail, &mut self.images); + crate::retail_render::spawn_sky(&map.name, root, &mut self.commands, + &mut self.meshes, &mut self.images, &mut self.sky); + } else { + crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials); + } + } + pub fn publish(mut self, world: &mut World) { + let owned = MapAssets { + meshes: self.meshes.publish(world), materials: self.materials.publish(world), + retail: self.retail.publish(world), sky: self.sky.publish(world), + images: self.images.publish(world), + }; + self.commands.0.apply(world); + world.insert_resource(owned); + } +} + +#[derive(Resource, Default)] +pub(crate) struct MapAssets { + meshes: Vec>, + materials: Vec>, + retail: Vec>, + sky: Vec>, + images: Vec>, +} +impl MapAssets { + pub fn retire(world: &mut World) { + let entities: Vec<_> = world.query_filtered::>().iter(world).collect(); + for entity in entities { world.despawn(entity); } + if let Some(owned) = world.remove_resource::() { + fn remove(world: &mut World, ids: Vec>) { + let mut assets = world.resource_mut::>(); + for id in ids { assets.remove(id); } + } + remove(world, owned.meshes); + remove(world, owned.materials); + remove(world, owned.retail); + remove(world, owned.sky); + remove(world, owned.images); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn world() -> World { + let mut world = World::new(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world + } + + #[test] + fn repeated_scene_retirement_releases_owned_assets_and_preserves_character() { + let mut world = world(); + let character_mesh = world.resource_mut::>().add(Cuboid::default()); + let character = world.spawn((crate::world::PlayerRoot, Mesh3d(character_mesh.clone()))).id(); + let map = skate_data::skate_map::SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap(); + for iteration in 0..6 { + let mut scene = PreparedScene::new(&world); + scene.prepare((iteration % 2 == 0).then_some(&map), std::path::Path::new("unused")); + // Preparation has not published any live entities or assets. + assert_eq!(world.resource::>().len(), 1); + scene.publish(&mut world); + assert!(world.query_filtered::>().iter(&world).count() > 0); + let old: Vec<_> = world.query_filtered::>().iter(&world).collect(); + MapAssets::retire(&mut world); + assert!(old.into_iter().all(|id| world.get_entity(id).is_err())); + assert!(world.get_entity(character).is_ok()); + assert_eq!(world.resource::>().len(), 1); + assert!(world.resource::>().contains(&character_mesh)); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.resource::>().len(), 0); + } + } + + #[test] + fn abandoned_preparation_does_not_replace_live_scene() { + let mut world = world(); + let mut active = PreparedScene::new(&world); + active.prepare(None, std::path::Path::new("unused")); + active.publish(&mut world); + let entities: Vec<_> = world.query_filtered::>().iter(&world).collect(); + let meshes = world.resource::>().len(); + let mut abandoned = PreparedScene::new(&world); + abandoned.prepare(None, std::path::Path::new("unused")); + drop(abandoned); + assert_eq!(world.resource::>().len(), meshes); + assert!(entities.into_iter().all(|id| world.get_entity(id).is_ok())); + } +} diff --git a/crates/skate-game/src/map_transition.rs b/crates/skate-game/src/map_transition.rs new file mode 100644 index 00000000..d02ff363 --- /dev/null +++ b/crates/skate-game/src/map_transition.rs @@ -0,0 +1,234 @@ +//! Transactional world replacement: prepare without mutating the live session, +//! then commit at a schedule boundary with gameplay suspended. +use bevy::prelude::*; +use std::{path::PathBuf, sync::{Arc, atomic::{AtomicU8, Ordering}}, thread::JoinHandle, time::Instant}; +use crate::{config::Config, map_library::Entry, map_render::PreparedScene, + physics::{GamePhysics, PlayerControls, SkaterRuntime}}; + +/// Persistent character customisation can reapply after this set, while the +/// transition still holds the loading overlay and gameplay remains suspended. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct MapTransitionSet; +#[derive(Message)] +pub(crate) struct WorldChanged { pub generation: u64 } + +#[cfg(test)] +#[path = "tests/map_transition.rs"] +mod tests; + +#[derive(Resource, Debug)] +pub(crate) struct CurrentMap { + pub path: Option, + pub name: String, + pub spawn: [f32; 3], + pub heading: f32, + pub generation: u64, +} +impl CurrentMap { + fn from_package(path: Option, map: Option<&skate_data::skate_map::SkateMap>) -> Self { + Self { path, name: map.map_or_else(|| "Test world".into(), |m| m.name.clone()), + spawn: map.map_or([0., crate::physics::ground::HEIGHT, 0.], |m| m.spawn), + heading: map.map_or(0., |m| m.heading), generation: 0 } + } +} + +struct PreparedWorld { + scene: PreparedScene, + physics: GamePhysics, + skater: SkaterRuntime, + controls: PlayerControls, + camera: crate::camera::CameraRuntime, + metadata: CurrentMap, + retail: bool, + difficulty: crate::difficulty::Difficulty, +} + +enum Phase { + Idle, + Requested(Entry), + Loading { entry: Entry, progress: Arc, job: JoinHandle> }, + // Let extraction see the committed scene before releasing the pause menu. + Publishing { frames: u8, notice: String }, +} +#[derive(Resource)] +pub(crate) struct MapTransition { phase: Phase } +impl Default for MapTransition { + fn default() -> Self { Self { phase: Phase::Idle } } +} +impl MapTransition { + pub fn busy(&self) -> bool { !matches!(self.phase, Phase::Idle) } + pub fn request(&mut self, entry: Entry) { + if !self.busy() { self.phase = Phase::Requested(entry); } + } + pub fn label(&self) -> String { + match &self.phase { + Phase::Idle => String::new(), + Phase::Requested(entry) => format!("Loading {} — preparing…", entry.label), + Phase::Loading { entry, progress, .. } => format!("Loading {} — {}…", entry.label, + match progress.load(Ordering::Relaxed) { + 0 => "reading and validating map", 1 => "building collision and rendering", + 2 => "initializing skater, camera and rendering", _ => "finishing meshes, textures and sky", + }), + Phase::Publishing { .. } => "Loading — publishing the new world…".into(), + } + } +} + +pub(crate) struct MapTransitionPlugin; +impl Plugin for MapTransitionPlugin { + fn build(&self, app: &mut App) { + let config = app.world().resource::(); + let current = CurrentMap::from_package(config.map_path.clone(), config.map.as_ref()); + app.insert_resource(current).init_resource::().add_message::() + .add_systems(PreUpdate, poll.in_set(MapTransitionSet).after(crate::graphics_menu::MenuInput) + .before(crate::input::poll_controllers)) + .configure_sets(FixedUpdate, ( + crate::app::SimulationSet::Input, crate::app::SimulationSet::Controls, + crate::app::SimulationSet::Physics, + ).run_if(crate::graphics_menu::gameplay_active)); + } +} + +fn start(world: &World, entry: Entry) -> Result { + let config = world.resource::(); + let root = config.asset_root.clone(); + let difficulty = config.difficulty; + let graphs = world.resource::().clone(); + let source = world.resource::().animation.source.clone(); + let preferences = world.resource::().preferences; + let mut scene = PreparedScene::new(world); + let selected = entry.clone(); + let progress = Arc::new(AtomicU8::new(0)); + let stage = progress.clone(); + let job = std::thread::Builder::new().name("map-loader".into()).spawn(move || { + let started = Instant::now(); + let map = selected.path.as_deref().map(skate_data::skate_map::SkateMap::load).transpose()?; + let read_time = started.elapsed(); + let validation_started = Instant::now(); + if let Some(map) = &map { crate::skate_world::validate_runtime(map)?; } + let validation_time = validation_started.elapsed(); + let metadata = CurrentMap::from_package(selected.path, map.as_ref()); + let retail = map.as_ref().is_some_and(|m| m.materials.iter().any(|m| m.retail_definition.is_some())); + // Both builders only read the decoded package. Reserve render handles + // on a second worker while the first constructs fresh simulation state; + // neither publishes to the live world until both have succeeded. + let (physics, skater, controls, camera, simulation_time, render_time) = + std::thread::scope(|scope| -> Result<_, String> { + let rendering = std::thread::Builder::new().name("map-render-loader".into()) + .spawn_scoped(scope, || { + let render_started = Instant::now(); + scene.prepare(map.as_ref(), &root); + render_started.elapsed() + }).map_err(|e| format!("Could not start render loader: {e}"))?; + let simulation_started = Instant::now(); + let simulation = (|| -> Result<_, String> { + stage.store(1, Ordering::Relaxed); + let physics = GamePhysics::load_with_difficulty(&root, map.as_ref(), difficulty)?; + stage.store(2, Ordering::Relaxed); + let skater = SkaterRuntime::load_for_world(&root, &graphs, &physics, difficulty.key(), Some(source))?; + let mut controls = PlayerControls::load(&root)?; + controls.preferences = preferences; + let camera = crate::camera::CameraRuntime::load(&root)?; + Ok((physics, skater, controls, camera)) + })(); + let simulation_time = simulation_started.elapsed(); + stage.store(3, Ordering::Relaxed); + // Explicitly join even on a simulation error: no background + // render work or reserved scene can outlive a failed request. + let render_time = rendering.join() + .map_err(|_| "Map render preparation failed unexpectedly".to_string())?; + let (physics, skater, controls, camera) = simulation?; + Ok((physics, skater, controls, camera, simulation_time, render_time)) + })?; + eprintln!("MAP_LOAD_TIMING name={:?} read_ms={} validation_ms={} simulation_ms={} render_ms={} prepare_ms={} parallel=true", + metadata.name, read_time.as_millis(), validation_time.as_millis(), + simulation_time.as_millis(), render_time.as_millis(), started.elapsed().as_millis()); + // Drop the decoded package on this worker. Physics and rendering now + // own their data; retaining it would double large-city CPU memory. + Ok(PreparedWorld { scene, physics, skater, controls, camera, metadata, retail, difficulty }) + }).map_err(|e| format!("Could not start map loader: {e}"))?; + Ok(Phase::Loading { entry, progress, job }) +} + +fn poll(world: &mut World) { + let ready = match &world.resource::().phase { + Phase::Idle => false, + Phase::Loading { job, .. } => job.is_finished(), + _ => true, + }; + if !ready { return; } + let phase = std::mem::replace(&mut world.resource_mut::().phase, Phase::Idle); + let next = match phase { + Phase::Requested(entry) => match start(world, entry) { + Ok(phase) => phase, + Err(error) => { failed(world, error); Phase::Idle } + }, + Phase::Loading { job, .. } => match job.join().unwrap_or_else(|_| Err("Map loader failed unexpectedly".into())) { + Ok(prepared) => { + let mut notice = commit(world, prepared); + let config = world.resource::(); + match crate::map_library::save_default(&config.asset_root, config.map_path.as_deref()) { + Ok(()) => notice.push_str(" Default map saved."), + Err(error) => notice.push_str(&format!(" Could not save default: {error}")), + } + Phase::Publishing { frames: 3, notice } + } + Err(error) => { failed(world, error); Phase::Idle } + }, + Phase::Publishing { frames, notice } if frames > 0 => Phase::Publishing { frames: frames - 1, notice }, + Phase::Publishing { notice, .. } => { + world.resource_mut::().transition_finished(notice, true); + world.resource_mut::>().unpause(); + Phase::Idle + } + Phase::Idle => Phase::Idle, + }; + world.resource_mut::().phase = next; +} + +fn failed(world: &mut World, error: String) { + warn!("MAP_TRANSITION_FAILED {error}"); + world.resource_mut::() + .transition_finished(format!("Could not load map: {error}\nPrevious world retained. Choose another map or Resume."), false); +} + +fn commit(world: &mut World, mut prepared: PreparedWorld) -> String { + let publication_started = Instant::now(); + prepared.metadata.generation = world.resource::().generation + 1; + // All fallible decoding/validation/construction finished before this point. + // No simulation system can observe a mixture of the two worlds. + crate::map_render::MapAssets::retire(world); + prepared.scene.publish(world); + crate::camera::set_world_environment(world, prepared.retail); + world.insert_resource(crate::retail_render::RetailScene(prepared.retail)); + world.insert_resource(crate::grind_world::GrindGeometry::for_world(prepared.metadata.path.is_none())); + world.insert_resource(Time::::from_duration(prepared.physics.period())); + let root_transform = Transform::from_matrix(crate::animation::native_matrix( + prepared.skater.animated_skeleton.roots.animation_to_world)); + for mut transform in world.query_filtered::<&mut Transform, With>().iter_mut(world) { + *transform = root_transform; + } + world.insert_resource(prepared.physics); + world.insert_resource(prepared.skater); + world.insert_resource(prepared.controls); + world.insert_resource(prepared.camera); + world.insert_resource(crate::presentation::Presentation::default()); + world.insert_resource(crate::replay::Replay::default()); + world.insert_resource(crate::input::ControllerInput::default()); + world.insert_resource(crate::input::PublishedTickInput::default()); + world.resource_mut::>().reset_all(); + world.resource_mut::>().reset_all(); + let mut config = world.resource_mut::(); + config.map_path = prepared.metadata.path.clone(); + config.difficulty = prepared.difficulty; + let notice = format!("Loaded {}.", prepared.metadata.name); + info!("MAP_TRANSITION_COMMITTED generation={} name={:?} spawn={:?} heading={} pid={}", + prepared.metadata.generation, prepared.metadata.name, prepared.metadata.spawn, + prepared.metadata.heading, std::process::id()); + if let Some(mut messages) = world.get_resource_mut::>() { + messages.write(WorldChanged { generation: prepared.metadata.generation }); + } + world.insert_resource(prepared.metadata); + eprintln!("MAP_PUBLISH_TIMING cpu_ms={}", publication_started.elapsed().as_millis()); + notice +} diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 38f053cf..a07f865a 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -175,6 +175,8 @@ impl GamePhysics { self.animation_profile.physics_mode = difficulty as u32; } + pub(crate) fn period(&self) -> std::time::Duration { self.clock.period() } + pub(crate) fn difficulty_index(&self) -> u32 { self.animation_profile.physics_mode } pub(crate) fn world(&self) -> &BoardWorld { diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 532ac365..38e31bfe 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -87,6 +87,13 @@ impl SkaterRuntime { graphs: &StockGraphs, physics: &GamePhysics, mode: &str, + ) -> Result { + Self::load_for_world(asset_root, graphs, physics, mode, None) + } + + pub(crate) fn load_for_world( + asset_root: &Path, graphs: &StockGraphs, physics: &GamePhysics, mode: &str, + source: Option>, ) -> Result { let data = Collections::load(asset_root)?; let ground_profiles = super::ground_runtime::GroundProfiles::load(&data)?; @@ -94,7 +101,10 @@ impl SkaterRuntime { .ok_or_else(|| format!("Invalid skater mode {mode}"))? as u32; // The host's current character is a custom skater with no pro selector // or equipped physical hat. These are profile choices, not force values. - let mut animation = SkaterAnimation::load(asset_root, &data, graphs, b"")?; + let mut animation = match source { + Some(source) => SkaterAnimation::from_source(&data, graphs, b"", source)?, + None => SkaterAnimation::load(asset_root, &data, graphs, b"")?, + }; let initial_hierarchy = animation.evaluate_initial_pose()?; let offboard = super::offboard::runtime::Runtime::load(&data, animation.motion.animation.metadata())?; let mut animated_skeleton = diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index ed162da5..4246aa7e 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -71,10 +71,10 @@ impl Material for RetailSkyMaterial { pub(crate) fn spawn_sky( name: &str, root: &std::path::Path, - commands: &mut Commands, - meshes: &mut Assets, - images: &mut Assets, - materials: &mut Assets, + commands: &mut crate::map_render::SceneCommands, + meshes: &mut impl crate::map_render::AssetSink, + images: &mut impl crate::map_render::AssetSink, + materials: &mut impl crate::map_render::AssetSink, ) { // Parks need their authored sky selection from the environment controller; // only these three district identities are currently resolved. diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index ee1903d2..51be56b1 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -422,11 +422,11 @@ fn render_groups( pub(crate) fn spawn( map: &SkateMap, - commands: &mut Commands, - meshes: &mut Assets, - materials: &mut Assets, - retail_materials: &mut Assets, - images: &mut Assets, + commands: &mut crate::map_render::SceneCommands, + meshes: &mut impl crate::map_render::AssetSink, + materials: &mut impl crate::map_render::AssetSink, + retail_materials: &mut impl crate::map_render::AssetSink, + images: &mut impl crate::map_render::AssetSink, ) { // Texture roles have different transfer functions even when sharing a record. let texture_ids = render_texture_ids(&map.textures); @@ -850,27 +850,23 @@ mod tests { fn render_adapter_creates_mesh_and_decoded_lightmap_without_a_window() { let map = demo(); let mut world = World::new(); - let mut queue = bevy::ecs::world::CommandQueue::default(); - let mut meshes = Assets::::default(); - let mut materials = Assets::::default(); - let mut images = Assets::::default(); - spawn( - &map, - &mut Commands::new(&mut queue, &world), - &mut meshes, - &mut materials, - &mut images, - ); - queue.apply(&mut world); - assert_eq!(meshes.len(), 1); - assert_eq!(materials.len(), 1); - assert_eq!(images.len(), 2); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + let mut scene = crate::map_render::PreparedScene::new(&world); + scene.prepare(Some(&map), std::path::Path::new("unused")); + scene.publish(&mut world); + assert_eq!(world.resource::>().len(), 1); + assert_eq!(world.resource::>().len(), 1); + assert_eq!(world.resource::>().len(), 2); assert_eq!(world.query::<&Mesh3d>().iter(&world).count(), 1); let lightmap = world .query::<&bevy::pbr::Lightmap>() .single(&world) .unwrap(); - let image = images.get(&lightmap.image).unwrap(); + let image = world.resource::>().get(&lightmap.image).unwrap(); assert_eq!(image.texture_descriptor.format, TextureFormat::Rgba16Float); let data = image.data.as_ref().unwrap(); let value = half::f16::from_le_bytes([data[0], data[1]]).to_f32(); diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index e07127d3..86c79630 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -30,7 +30,21 @@ use skate_core::{ riding::push_behaviors::PushFootFrame, }; use skate_data::collections::Collections; -use std::path::Path; +use std::{path::Path, sync::Arc}; + +/// Immutable bank bytes and decoded poses survive world changes. +pub(crate) struct AnimationSource { + banks: skate_data::animation_banks::AnimationBanks, + evaluator: Arc, +} +impl AnimationSource { + fn load(root: &Path) -> Result, String> { + let banks = skate_data::animation_banks::AnimationBanks::load(root)?; + let mut evaluator = PoseEvaluator::from_banks(&banks)?; + evaluator.load_authored_clips(root)?; + Ok(Arc::new(Self { banks, evaluator: Arc::new(evaluator) })) + } +} /// Completed native publications, never inferred from a render transform. pub(crate) struct AnimationPhysical { @@ -51,7 +65,8 @@ pub(crate) struct AnimationPhysical { pub(crate) struct SkaterAnimation { pub action: ActionHost, pub motion: MotionHost, - pub evaluator: PoseEvaluator, + pub evaluator: Arc, + pub source: Arc, pub pose: Vec, pub packet: PhysicsPosePacket, pub attributes: PacketAttributes, @@ -82,15 +97,19 @@ impl SkaterAnimation { graphs: &StockGraphs, pro_skater: &[u8], ) -> Result { - let banks = skate_data::animation_banks::AnimationBanks::load(root)?; - let mut evaluator = PoseEvaluator::from_banks(&banks)?; - evaluator.load_authored_clips(root)?; + Self::from_source(data, graphs, pro_skater, AnimationSource::load(root)?) + } + + pub fn from_source( + data: &Collections, graphs: &StockGraphs, pro_skater: &[u8], source: Arc, + ) -> Result { + let evaluator = source.evaluator.clone(); let state = state::AnimationState::new(true); let count = evaluator.frames.bone_names.len(); let mut motion = MotionHost::from_graph( &graphs.motion, data, - banks.metadata()?, + source.banks.metadata()?, PlaybackContext { is_switch: Some(state.switch()), is_mirrored: Some(state.mirrored()), @@ -123,6 +142,7 @@ impl SkaterAnimation { .map_err(|error| format!("Stock ActionGraph initialization: {error}"))?, motion, evaluator, + source, pose: Vec::new(), attributes: PacketAttributes::default(), state, diff --git a/crates/skate-game/src/tests/map_transition.rs b/crates/skate-game/src/tests/map_transition.rs new file mode 100644 index 00000000..2f0d7768 --- /dev/null +++ b/crates/skate-game/src/tests/map_transition.rs @@ -0,0 +1,99 @@ +//! Construction/lifetime checks only. Never advance physics or drive gameplay. +use super::*; + +#[test] +fn a_pending_request_cannot_be_replaced_by_another_menu_action() { + let mut transition = MapTransition::default(); + transition.request(Entry { label: "first".into(), path: None }); + transition.request(Entry { label: "second".into(), path: Some("other.skate".into()) }); + assert!(transition.busy()); + assert!(matches!(&transition.phase, Phase::Requested(entry) if entry.label == "first")); +} + +#[test] +#[ignore = "requires private installed assets; CPU lifecycle only, no gameplay"] +fn installed_worlds_prepare_commit_and_retire_without_simulation() { + let root = PathBuf::from(std::env::var("SKATE_TRANSITION_TEST_ASSETS").unwrap()).canonicalize().unwrap(); + let manifest = skate_data::GameAssets::load(&root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(&root, &manifest).unwrap(); + let difficulty = crate::difficulty::Difficulty::Hardcore; + let physics = GamePhysics::load_with_difficulty(&root, None, difficulty).unwrap(); + let skater = SkaterRuntime::load(&root, &graphs, &physics, difficulty.key()).unwrap(); + let source = skater.animation.source.clone(); + let evaluator = skater.animation.evaluator.clone(); + let mut controls = PlayerControls::load(&root).unwrap(); + controls.preferences = skate_core::input::riding_intentions::PushPreferences { + automatic_push_enabled: true, automatic_push_right: true + }; + let mut world = World::new(); + world.insert_resource(Config { asset_root: root.clone(), verification_capture: None, + map: None, map_path: None, difficulty, check_assets: false, start_paused: false }); + world.insert_resource(CurrentMap::from_package(None, None)); + world.insert_resource(graphs); + world.insert_resource(physics); + world.insert_resource(skater); + world.insert_resource(controls); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + let character = world.spawn((crate::world::PlayerRoot, Transform::default())).id(); + let mut initial = PreparedScene::new(&world); + initial.prepare(None, &root); + initial.publish(&mut world); + let procedural_meshes = world.resource::>().len(); + let procedural_entities = world.query_filtered::>().iter(&world).count(); + + for (iteration, file) in [Some("BlackBoxPark.skate"), None, Some("University.skate"), None, + Some("BlackBoxPark.skate"), None].into_iter().enumerate() { + let entry = Entry { label: file.unwrap_or("Test world").into(), + path: file.map(|name| root.parent().unwrap().join("maps").join(name)) }; + let old: Vec<_> = world.query_filtered::>().iter(&world).collect(); + world.resource_mut::().contact_count = 123; + world.resource_mut::().pose_generation = 999; + let Phase::Loading { job, .. } = start(&world, entry).unwrap() else { panic!("expected worker"); }; + let prepared = job.join().unwrap().unwrap(); + assert!(old.iter().all(|&id| world.get_entity(id).is_ok()), "preparation changed live scene"); + assert_eq!(world.resource::().contact_count, 123); + let triangles = prepared.physics.world_triangles().len(); + let spawn = prepared.metadata.spawn; + assert!(Arc::ptr_eq(&prepared.skater.animation.source, &source)); + assert!(Arc::ptr_eq(&prepared.skater.animation.evaluator, &evaluator)); + commit(&mut world, prepared); + assert!(old.into_iter().all(|id| world.get_entity(id).is_err())); + assert!(world.get_entity(character).is_ok()); + assert_eq!(world.resource::().generation, iteration as u64 + 1); + assert_eq!(world.resource::().spawn, spawn); + let physics = world.resource::(); + assert_eq!(physics.world_triangles().len(), triangles); + assert_eq!(physics.contact_count, 0); + assert_eq!(physics.ticks, 0); + assert!(!physics.failed); + assert_eq!(physics.difficulty_index(), difficulty as u32); + assert_eq!(world.resource::().pose_generation, 0); + assert_eq!(world.resource::().ticks, 0); + assert!(world.resource::().preferences.automatic_push_enabled); + assert!(world.resource::().preferences.automatic_push_right); + assert!(world.resource::().pair().is_none()); + assert!(!world.resource::().active); + assert!(world.resource::().frame.is_none()); + if file.is_none() { + assert_eq!(world.resource::>().len(), procedural_meshes); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.query_filtered::>().iter(&world).count(), procedural_entities); + } + eprintln!("LIFECYCLE_CHECK generation={} map={file:?} triangles={triangles} entities={} meshes={} images={}", + iteration + 1, world.query_filtered::>().iter(&world).count(), world.resource::>().len(), world.resource::>().len()); + } + let missing = Entry { label: "missing".into(), path: Some(root.join("nonexistent-transition-check.skate")) }; + let Phase::Loading { job, .. } = start(&world, missing).unwrap() else { panic!("expected worker"); }; + assert!(job.join().unwrap().is_err()); + assert_eq!(world.resource::().generation, 6); + assert_eq!(world.resource::>().len(), procedural_meshes); + assert!(world.get_entity(character).is_ok()); +} diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index a8debf70..7a6bd479 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -17,31 +17,28 @@ impl Plugin for WorldPlugin { .add_systems(Startup, spawn); } } -fn spawn( - mut commands: Commands, - server: Res, - manifest: Res, - mut meshes: ResMut>, - mut materials: ResMut>, - mut retail_materials: ResMut>, - mut sky_materials: ResMut>, - mut images: ResMut>, - mut config: ResMut, +fn spawn(world: &mut World) { + let scene = { + let server = world.resource::(); + let manifest = world.resource::(); + server.load(GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone())) + }; + world.spawn((PlayerRoot, Transform::default(), Visibility::default())) + .with_children(|parent| { parent.spawn(SceneRoot(scene)); }); + let mut prepared = crate::map_render::PreparedScene::new(world); + let (map, root) = { + let mut config = world.resource_mut::(); + (config.map.take(), config.asset_root.clone()) + }; + prepared.prepare(map.as_ref(), &root); + prepared.publish(world); +} + +pub(crate) fn spawn_test_world( + commands: &mut crate::map_render::SceneCommands, + meshes: &mut impl crate::map_render::AssetSink, + materials: &mut impl crate::map_render::AssetSink, ) { - commands - .spawn((PlayerRoot, Transform::default(), Visibility::default())) - .with_children(|parent| { - parent.spawn(SceneRoot(server.load( - GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone()), - ))); - }); - if let Some(map) = config.map.take() { - // Physics has already consumed the package. Render assets own their - // uploaded data; keeping another full city package wastes gigabytes. - crate::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images); - crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials); - return; - } let colors = [ Color::srgb(0.16, 0.19, 0.21), Color::srgb(0.48, 0.35, 0.22), diff --git a/docs/map-transitions.md b/docs/map-transitions.md new file mode 100644 index 00000000..0ea4e606 --- /dev/null +++ b/docs/map-transitions.md @@ -0,0 +1,148 @@ +# In-process map transitions + +Escape → Map selects an installed package; Load map changes the world in the +current process and resumes at the package spawn/heading. Loading shows its +current stage and suspends gameplay/menu actions until publication completes. +A failed preparation leaves the previous world available through Resume. + +Successful changes save `settings/default-map.json`. Existing string pointers +remain compatible; JSON `null` explicitly selects the procedural test world. +Explicit `--map` and `--test-world` still override this saved default at launch. +`--start-paused` opens the menu before any gameplay ticks. + +## Ownership and integration + +- `map_transition.rs`: worker lifecycle, fresh simulation construction, atomic + ECS replacement, error handling and compact `CurrentMap` metadata. No process + spawn or restart path remains in the map selector. +- `map_render.rs`: `PreparedScene`, live-allocator asset reservations, + `SceneCommands`, `MapEntity` and explicit `MapAssets` retirement. CPU map + decoding, collision/query construction, mesh batching, textures and sky + preparation happen on the worker. Only publication touches live ECS/assets. +- `world.rs`, `skate_world.rs`, `retail_render.rs`: initial startup and transitions + share the scene builder. Rendering calculations are unchanged. All world/sky + meshes, materials, images and lights have map ownership. +- `skater_animation.rs`, `physics/skater.rs`: immutable animation banks and + `Arc` survive replacements, while graph/controller/skeleton + state is constructed afresh. Existing character entities and bone bindings + remain alive. +- `camera.rs`: camera runtime/history resets, and native HDR/tone components are + installed or removed without replacing the presentation target or graphics + configuration. Scene publication restores environment defaults first. +- `graphics_menu.rs`, `config.rs`, `map_library.rs`: asynchronous request UI, + pause, current difficulty and default-map persistence. +- `physics.rs`, `grind_world.rs`, `app.rs`, `main.rs`: schedule/cadence, grind + geometry and plugin wiring. Board collision/contact/query/grind state, input + history, replay and presentation snapshots are replaced together. + +Shared-file integration notes: + +1. Renderer work splitting `retail_sky.rs` must retain the `SceneCommands` and + generic `AssetSink` interfaces. Its additional world-material argument belongs + in `PreparedScene::prepare` after world construction. If sky setup mutates + materials, add a mutable iteration operation to both asset sink implementations. +2. Customiser state stored in its own resource survives map changes. The + `WorldChanged { generation }` message and `MapTransitionSet` allow reapplying + selection in PreUpdate after commit, while the loading overlay remains up. + This branch does not add a row referencing the separate customiser module. +3. Trick changes should preserve the `SkaterRuntime::load_for_world` wrapper and + shared animation source; its normal constructor still initializes all fields. + +## Validation recorded on 2026-09-08 + +Release build passed with Bevy 0.18.1 and local patches: + +```text +cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --no-default-features +``` + +Used the existing shared target directory and static CRT target flags. The +executable was immediately copied into this task's own output directory. + +Seven focused checks passed: repeated scene/asset retirement, abandoned scene +preparation, native/procedural camera components, saved-default round trips, +overlapping requests, render/lightmap construction and retained graphics controls. + +A CPU-only installed-content lifecycle check completed six replacements: +BlackBoxPark → test world → University → test world → BlackBoxPark → test world. +University constructed 1,133,649 collision triangles, 4,866 meshes including sky, +and 2,057 images. Every return to the procedural scene restored four meshes, +zero map images and zero native materials. Old map entity IDs were invalidated; +character identity and animation Arc identities survived; contacts, ticks, +camera/replay/presentation history reset. Difficulty and push preferences survived. +A missing package failed without changing the current world. No simulation tick +was advanced by this check, and it did not write installed settings or maps. + +A paused startup capture exited successfully with zero input polls, zero physics +ticks and zero animation ticks; the pause menu and 3,324 stock clips initialized. +This ran before the later instruction restricting runtime checks to the user. +Subsequent work used only compilation/static checks and prepared user launchers. + +## Limits of the evidence + +Repeated rendered/gameplay switches have not been exercised. GPU resource +retirement, driver memory behavior and playability after transitions require user +testing. ECS publication and GPU uploads/pipeline creation can still cause a +short hitch; the large CPU preparation phase stays off the UI thread. Retaining +the previous map until preparation succeeds temporarily needs memory for both +worlds. The loading overlay remains for several publication frames; this is not +a claim that every shader pipeline has finished compiling. Native sky availability +matches this branch's baseline renderer and must be integrated with the separate +authored-sky work. No map/character content is committed or bundled. + +## Loading overlap follow-up + +After the user confirmed the initial implementation works, the loader was changed +to prepare rendering concurrently with collision/skater/camera construction. +One additional scoped worker reads the same immutable decoded map; it does not +copy the package or cache old worlds. Both builders must finish before commit, +and the render worker is joined even if simulation construction fails. The +existing publication delay, validation and rollback behavior remain in place. + +`MAP_LOAD_TIMING` in stderr reports file read/decode, validation, simulation, +render preparation and aggregate preparation milliseconds. Simulation and render +durations overlap, so they must not be added to estimate elapsed time. +`MAP_PUBLISH_TIMING` reports main-world publication CPU time, excluding subsequent +GPU work and the menu's publication-frame delay. + +The follow-up is compile-checked only; no runtime checks or benchmarks were run. +Its actual speed improvement is unmeasured. Independent work can overlap, but +CPU/memory contention and the fraction spent reading/decoding or uploading to the +GPU determine the benefit on a particular map and machine. The earlier runtime +validation above describes the original sequential preparation build. + +## Parallel package decoding follow-up + +The user's timing logs showed Downtown spending 3,041 ms in combined read/parse, +compared with 1,304 ms render preparation and 490 ms simulation construction. +A standalone `skate-data` example isolated actual file reads at roughly 60 ms. +This tool only parses packages; it does not initialize Bevy, physics or gameplay. + +The reader now scans texture descriptors and borrows their compressed payloads, +then decodes them with up to four workers by default. Dynamic assignment handles +unequal texture sizes; original indices determine the final texture order. +The workers finish before subsequent map processing or any world publication. +Maps with less than 1 MiB of decoded textures stay serial. Existing storage +methods, output-size bounds, DEFLATE input-consumption checks and format validation +are retained. Decompression buffers reserve a bounded initial capacity to reduce +reallocation. No content conversion, on-disk cache or persistent map cache is added. + +Three-pass data measurements on the installed packages (milliseconds): + +| Map | Previous parse times | Parallel parse times | +| --- | --- | --- | +| Downtown | 2934, 2929, 2777 | 1440, 1418, 1481 | +| Industrial | 1526, 1520, 1504 | 744, 679, 682 | + +Separate full-field serial/parallel comparisons passed for Downtown, Industrial +and University. Two decoder tests passed for mixed raw/zlib/zstd storage, output +order at 2/4/8 workers, corrupt payloads, wrong sizes, unknown methods and DEFLATE +trailing bytes. All four existing format integration tests passed, including +documented versions and malformed/truncated input. These are data checks only. + +`MAP_READ_TIMING` now separates `disk_ms` and `parse_ms`. +`MAP_DECODE_TIMING` divides parsing into textures, geometry and extensions, and +reports actual texture worker count. The existing total preparation/publication +timings remain available. End-to-end game loading improvement remains for the +user to test; the approximately halved decode time is not a claim of halved total +loading time. Game code was compiled; the new game build was not launched.