Switch maps in process with asynchronous loading and parallel decoding

This commit is contained in:
chazmm committed 2026-09-08 15:48:05 +01:00
1 parent da364c924f
commit 43943a71bc
20 files changed
+1074 -137

No files matched your search

+21
View File
@@ -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);
}
}
}
}
+85 -48
View File
@@ -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<Route>,
pub extensions: Vec<Extension>,
}
#[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<Vec<u8>>,
}
#[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<u8>,
}
#[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<Vertex>,
pub indices: Vec<u32>,
pub collision: Vec<Collision>,
}
#[derive(Debug)]
#[derive(Debug, PartialEq)]
pub struct Rail {
pub name: String,
pub closed: bool,
pub points: Vec<[f32; 3]>,
pub native: Option<Vec<u8>>,
}
#[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<Vec<u8>, String> {
fn stored_block(&mut self, expected: usize) -> Result<StoredBlock<'a>, 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<Vec<u8>, 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<Vec<u8>, 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<Self, String> {
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<Self, String> {
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<Self, String> {
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,
@@ -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<usize, String> {
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<Vec<(usize, Vec<u8>)>, 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<Vec<u8>> = (0..16).map(|i| vec![i as u8; (i + 1) * 32 * 1024]).collect();
let mut encoded: Vec<Vec<u8>> = 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::<Vec<_>>();
{
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());
}
}
+1
View File
@@ -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,
+40
View File
@@ -68,3 +68,43 @@ fn present(mut runtime: ResMut<CameraRuntime>, 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::<Entity, With<GameplayCamera>>().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::<Camera>().unwrap().is_active = false;
*camera.get_mut::<Transform>().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::<bevy::render::view::Hdr>(), retail);
assert_eq!(entity.contains::<crate::retail_render::RetailTone>(), retail);
assert_eq!(*entity.get::<Msaa>().unwrap(), Msaa::Sample4);
assert!(!entity.get::<Camera>().unwrap().is_active);
assert_eq!(*entity.get::<bevy::core_pipeline::tonemapping::Tonemapping>().unwrap(),
if retail { bevy::core_pipeline::tonemapping::Tonemapping::None }
else { bevy::core_pipeline::tonemapping::Tonemapping::default() });
}
}
}
+4 -1
View File
@@ -9,6 +9,7 @@ pub(crate) struct Config {
pub map_path: Option<PathBuf>,
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]"
));
}
}
+31 -9
View File
@@ -85,6 +85,12 @@ pub(crate) struct Menu {
maps: Vec<crate::map_library::Entry>,
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<Res<Menu>>) -> 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<PrimaryWindow>>,
cameras: Query<Entity, With<Camera3d>>,
adapter: Res<RenderAdapter>,
mut time: ResMut<Time<Virtual>>,
) {
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<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
values[(index + direction).rem_euclid(values.len() as i32) as usize]
}
fn interact(
config: Res<crate::config::Config>,
mut config: ResMut<crate::config::Config>,
mut transition: ResMut<crate::map_transition::MapTransition>,
mut physics: ResMut<crate::physics::GamePhysics>,
keys: Res<ButtonInput<KeyCode>>,
mut menu: ResMut<Menu>,
@@ -244,6 +257,11 @@ fn interact(
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
mut exit: MessageWriter<AppExit>,
) {
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<Menu>,
transition: Res<crate::map_transition::MapTransition>,
time: Res<Time<Real>>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
@@ -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));
+8 -5
View File
@@ -15,11 +15,7 @@ pub(crate) struct GrindGeometryPlugin;
impl Plugin for GrindGeometryPlugin {
fn build(&self, app: &mut App) {
let test_world = app.world().resource::<crate::config::Config>().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,
+2
View File
@@ -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;
+35 -18
View File
@@ -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<PathBuf>,
@@ -17,7 +17,7 @@ pub(crate) fn discover(assets: &Path) -> Vec<Entry> {
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<Option<PathBuf>, 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<String> = 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<Option<PathBuf>, 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(())
}
+192
View File
@@ -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<A: Asset> {
fn add(&mut self, asset: A) -> Handle<A>;
}
impl<A: Asset> AssetSink<A> for Assets<A> {
fn add(&mut self, asset: A) -> Handle<A> { Assets::add(self, asset) }
}
pub(crate) struct StagedAssets<A: Asset> {
provider: AssetHandleProvider,
values: Vec<(Handle<A>, A)>,
}
impl<A: Asset> StagedAssets<A> {
fn new(world: &World) -> Self {
Self { provider: world.resource::<Assets<A>>().get_handle_provider(), values: Vec::new() }
}
fn publish(self, world: &mut World) -> Vec<AssetId<A>> {
let mut assets = world.resource_mut::<Assets<A>>();
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<A: Asset> AssetSink<A> for StagedAssets<A> {
fn add(&mut self, asset: A) -> Handle<A> {
let handle = self.provider.reserve_handle().typed::<A>();
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<std::sync::OnceLock<Entity>>,
}
impl SceneCommands {
pub fn spawn<B: Bundle>(&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<R: Resource>(&mut self, resource: R) {
self.0.push(move |world: &mut World| { world.insert_resource(resource); });
}
}
impl SceneEntity<'_> {
pub fn insert<B: Bundle>(&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<Mesh>,
materials: StagedAssets<StandardMaterial>,
retail: StagedAssets<RetailWorldMaterial>,
sky: StagedAssets<RetailSkyMaterial>,
images: StagedAssets<Image>,
}
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<AssetId<Mesh>>,
materials: Vec<AssetId<StandardMaterial>>,
retail: Vec<AssetId<RetailWorldMaterial>>,
sky: Vec<AssetId<RetailSkyMaterial>>,
images: Vec<AssetId<Image>>,
}
impl MapAssets {
pub fn retire(world: &mut World) {
let entities: Vec<_> = world.query_filtered::<Entity, With<MapEntity>>().iter(world).collect();
for entity in entities { world.despawn(entity); }
if let Some(owned) = world.remove_resource::<Self>() {
fn remove<A: Asset>(world: &mut World, ids: Vec<AssetId<A>>) {
let mut assets = world.resource_mut::<Assets<A>>();
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::<Assets<Mesh>>();
world.init_resource::<Assets<StandardMaterial>>();
world.init_resource::<Assets<RetailWorldMaterial>>();
world.init_resource::<Assets<RetailSkyMaterial>>();
world.init_resource::<Assets<Image>>();
world
}
#[test]
fn repeated_scene_retirement_releases_owned_assets_and_preserves_character() {
let mut world = world();
let character_mesh = world.resource_mut::<Assets<Mesh>>().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::<Assets<Mesh>>().len(), 1);
scene.publish(&mut world);
assert!(world.query_filtered::<Entity, With<MapEntity>>().iter(&world).count() > 0);
let old: Vec<_> = world.query_filtered::<Entity, With<MapEntity>>().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::<Assets<Mesh>>().len(), 1);
assert!(world.resource::<Assets<Mesh>>().contains(&character_mesh));
assert_eq!(world.resource::<Assets<StandardMaterial>>().len(), 0);
assert_eq!(world.resource::<Assets<RetailWorldMaterial>>().len(), 0);
assert_eq!(world.resource::<Assets<RetailSkyMaterial>>().len(), 0);
assert_eq!(world.resource::<Assets<Image>>().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::<Entity, With<MapEntity>>().iter(&world).collect();
let meshes = world.resource::<Assets<Mesh>>().len();
let mut abandoned = PreparedScene::new(&world);
abandoned.prepare(None, std::path::Path::new("unused"));
drop(abandoned);
assert_eq!(world.resource::<Assets<Mesh>>().len(), meshes);
assert!(entities.into_iter().all(|id| world.get_entity(id).is_ok()));
}
}
+234
View File
@@ -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<PathBuf>,
pub name: String,
pub spawn: [f32; 3],
pub heading: f32,
pub generation: u64,
}
impl CurrentMap {
fn from_package(path: Option<PathBuf>, 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<AtomicU8>, job: JoinHandle<Result<PreparedWorld, String>> },
// 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::<Config>();
let current = CurrentMap::from_package(config.map_path.clone(), config.map.as_ref());
app.insert_resource(current).init_resource::<MapTransition>().add_message::<WorldChanged>()
.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<Phase, String> {
let config = world.resource::<Config>();
let root = config.asset_root.clone();
let difficulty = config.difficulty;
let graphs = world.resource::<crate::graph_runtime::StockGraphs>().clone();
let source = world.resource::<SkaterRuntime>().animation.source.clone();
let preferences = world.resource::<PlayerControls>().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::<MapTransition>().phase {
Phase::Idle => false,
Phase::Loading { job, .. } => job.is_finished(),
_ => true,
};
if !ready { return; }
let phase = std::mem::replace(&mut world.resource_mut::<MapTransition>().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::<Config>();
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::<crate::graphics_menu::Menu>().transition_finished(notice, true);
world.resource_mut::<Time<Virtual>>().unpause();
Phase::Idle
}
Phase::Idle => Phase::Idle,
};
world.resource_mut::<MapTransition>().phase = next;
}
fn failed(world: &mut World, error: String) {
warn!("MAP_TRANSITION_FAILED {error}");
world.resource_mut::<crate::graphics_menu::Menu>()
.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::<CurrentMap>().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::<Fixed>::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<crate::world::PlayerRoot>>().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::<ButtonInput<KeyCode>>().reset_all();
world.resource_mut::<ButtonInput<MouseButton>>().reset_all();
let mut config = world.resource_mut::<Config>();
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<WorldChanged>>() {
messages.write(WorldChanged { generation: prepared.metadata.generation });
}
world.insert_resource(prepared.metadata);
eprintln!("MAP_PUBLISH_TIMING cpu_ms={}", publication_started.elapsed().as_millis());
notice
}
+2
View File
@@ -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 {
+11 -1
View File
@@ -87,6 +87,13 @@ impl SkaterRuntime {
graphs: &StockGraphs,
physics: &GamePhysics,
mode: &str,
) -> Result<Self, String> {
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<std::sync::Arc<crate::skater_animation::AnimationSource>>,
) -> Result<Self, String> {
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 =
+4 -4
View File
@@ -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<Mesh>,
images: &mut Assets<Image>,
materials: &mut Assets<RetailSkyMaterial>,
commands: &mut crate::map_render::SceneCommands,
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
images: &mut impl crate::map_render::AssetSink<Image>,
materials: &mut impl crate::map_render::AssetSink<RetailSkyMaterial>,
) {
// Parks need their authored sky selection from the environment controller;
// only these three district identities are currently resolved.
+17 -21
View File
@@ -422,11 +422,11 @@ fn render_groups(
pub(crate) fn spawn(
map: &SkateMap,
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
retail_materials: &mut Assets<crate::retail_render::RetailWorldMaterial>,
images: &mut Assets<Image>,
commands: &mut crate::map_render::SceneCommands,
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
retail_materials: &mut impl crate::map_render::AssetSink<crate::retail_render::RetailWorldMaterial>,
images: &mut impl crate::map_render::AssetSink<Image>,
) {
// 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::<Mesh>::default();
let mut materials = Assets::<StandardMaterial>::default();
let mut images = Assets::<Image>::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::<Assets<Mesh>>();
world.init_resource::<Assets<StandardMaterial>>();
world.init_resource::<Assets<crate::retail_render::RetailWorldMaterial>>();
world.init_resource::<Assets<crate::retail_render::RetailSkyMaterial>>();
world.init_resource::<Assets<Image>>();
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::<Assets<Mesh>>().len(), 1);
assert_eq!(world.resource::<Assets<StandardMaterial>>().len(), 1);
assert_eq!(world.resource::<Assets<Image>>().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::<Assets<Image>>().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();
+26 -6
View File
@@ -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<PoseEvaluator>,
}
impl AnimationSource {
fn load(root: &Path) -> Result<Arc<Self>, 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<PoseEvaluator>,
pub source: Arc<AnimationSource>,
pub pose: Vec<Sqt>,
pub packet: PhysicsPosePacket,
pub attributes: PacketAttributes,
@@ -82,15 +97,19 @@ impl SkaterAnimation {
graphs: &StockGraphs,
pro_skater: &[u8],
) -> Result<Self, String> {
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<AnimationSource>,
) -> Result<Self, String> {
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,
@@ -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::<ButtonInput<KeyCode>>();
world.init_resource::<ButtonInput<MouseButton>>();
world.init_resource::<Assets<Mesh>>();
world.init_resource::<Assets<Image>>();
world.init_resource::<Assets<StandardMaterial>>();
world.init_resource::<Assets<crate::retail_render::RetailWorldMaterial>>();
world.init_resource::<Assets<crate::retail_render::RetailSkyMaterial>>();
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::<Assets<Mesh>>().len();
let procedural_entities = world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().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::<Entity, With<crate::map_render::MapEntity>>().iter(&world).collect();
world.resource_mut::<GamePhysics>().contact_count = 123;
world.resource_mut::<SkaterRuntime>().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::<GamePhysics>().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::<CurrentMap>().generation, iteration as u64 + 1);
assert_eq!(world.resource::<CurrentMap>().spawn, spawn);
let physics = world.resource::<GamePhysics>();
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::<SkaterRuntime>().pose_generation, 0);
assert_eq!(world.resource::<PlayerControls>().ticks, 0);
assert!(world.resource::<PlayerControls>().preferences.automatic_push_enabled);
assert!(world.resource::<PlayerControls>().preferences.automatic_push_right);
assert!(world.resource::<crate::presentation::Presentation>().pair().is_none());
assert!(!world.resource::<crate::replay::Replay>().active);
assert!(world.resource::<crate::camera::CameraRuntime>().frame.is_none());
if file.is_none() {
assert_eq!(world.resource::<Assets<Mesh>>().len(), procedural_meshes);
assert_eq!(world.resource::<Assets<Image>>().len(), 0);
assert_eq!(world.resource::<Assets<crate::retail_render::RetailSkyMaterial>>().len(), 0);
assert_eq!(world.resource::<Assets<crate::retail_render::RetailWorldMaterial>>().len(), 0);
assert_eq!(world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().iter(&world).count(), procedural_entities);
}
eprintln!("LIFECYCLE_CHECK generation={} map={file:?} triangles={triangles} entities={} meshes={} images={}",
iteration + 1, world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().iter(&world).count(), world.resource::<Assets<Mesh>>().len(), world.resource::<Assets<Image>>().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::<CurrentMap>().generation, 6);
assert_eq!(world.resource::<Assets<Mesh>>().len(), procedural_meshes);
assert!(world.get_entity(character).is_ok());
}
+21 -24
View File
@@ -17,31 +17,28 @@ impl Plugin for WorldPlugin {
.add_systems(Startup, spawn);
}
}
fn spawn(
mut commands: Commands,
server: Res<AssetServer>,
manifest: Res<AssetManifest>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut retail_materials: ResMut<Assets<crate::retail_render::RetailWorldMaterial>>,
mut sky_materials: ResMut<Assets<crate::retail_render::RetailSkyMaterial>>,
mut images: ResMut<Assets<Image>>,
mut config: ResMut<crate::config::Config>,
fn spawn(world: &mut World) {
let scene = {
let server = world.resource::<AssetServer>();
let manifest = world.resource::<AssetManifest>();
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::<crate::config::Config>();
(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<Mesh>,
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
) {
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),
+148
View File
@@ -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<PoseEvaluator>` 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.