From 1d89d85cc54748b3a3cc6214e53d23aa99b24ba0 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 15:42:29 +0100 Subject: [PATCH 1/5] Switch maps in process with asynchronous loading and parallel decoding --- crates/skate-data/examples/map_decode.rs | 21 ++ crates/skate-data/src/skate_map.rs | 133 ++++++---- .../src/skate_map/texture_decode.rs | 93 +++++++ crates/skate-game/src/app.rs | 1 + crates/skate-game/src/camera.rs | 40 +++ crates/skate-game/src/config.rs | 5 +- crates/skate-game/src/graphics_menu.rs | 40 ++- crates/skate-game/src/grind_world.rs | 13 +- crates/skate-game/src/main.rs | 2 + crates/skate-game/src/map_library.rs | 53 ++-- crates/skate-game/src/map_render.rs | 192 ++++++++++++++ crates/skate-game/src/map_transition.rs | 234 ++++++++++++++++++ crates/skate-game/src/physics.rs | 2 + crates/skate-game/src/physics/skater.rs | 12 +- crates/skate-game/src/retail_render.rs | 8 +- crates/skate-game/src/skate_world.rs | 38 ++- crates/skate-game/src/skater_animation.rs | 32 ++- crates/skate-game/src/tests/map_transition.rs | 99 ++++++++ crates/skate-game/src/world.rs | 45 ++-- docs/map-transitions.md | 148 +++++++++++ 20 files changed, 1074 insertions(+), 137 deletions(-) create mode 100644 crates/skate-data/examples/map_decode.rs create mode 100644 crates/skate-data/src/skate_map/texture_decode.rs create mode 100644 crates/skate-game/src/map_render.rs create mode 100644 crates/skate-game/src/map_transition.rs create mode 100644 crates/skate-game/src/tests/map_transition.rs create mode 100644 docs/map-transitions.md 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. From 5e2310d498afd4d81d357ca2cad46bd583cb8d8f Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:41:16 +0100 Subject: [PATCH 2/5] Add native session-marker controls, private HUD extraction and return effect --- .gitignore | 1 + PLAY-SESSION-MARKER.bat | 20 ++ crates/skate-game/src/app.rs | 1 + crates/skate-game/src/input/controllers.rs | 13 ++ crates/skate-game/src/main.rs | 1 + crates/skate-game/src/physics/player_state.rs | 5 +- .../skate-game/src/physics/teleport_state.rs | 9 + .../skate-game/src/session_marker/effect.rs | 126 +++++++++++ .../skate-game/src/session_marker/effect.wgsl | 17 ++ crates/skate-game/src/session_marker/hud.rs | 177 +++++++++++++++ crates/skate-game/src/session_marker/mod.rs | 204 ++++++++++++++++++ crates/skate-game/src/session_marker/noise.rs | 64 ++++++ crates/skate-game/src/session_marker/state.rs | 98 +++++++++ .../src/session_marker/validation.rs | 48 +++++ crates/skate-game/src/skater_animation.rs | 14 ++ docs/session-markers.md | 118 ++++++++++ tools/extract_session_marker.py | 131 +++++++++++ 17 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 PLAY-SESSION-MARKER.bat create mode 100644 crates/skate-game/src/session_marker/effect.rs create mode 100644 crates/skate-game/src/session_marker/effect.wgsl create mode 100644 crates/skate-game/src/session_marker/hud.rs create mode 100644 crates/skate-game/src/session_marker/mod.rs create mode 100644 crates/skate-game/src/session_marker/noise.rs create mode 100644 crates/skate-game/src/session_marker/state.rs create mode 100644 crates/skate-game/src/session_marker/validation.rs create mode 100644 docs/session-markers.md create mode 100644 tools/extract_session_marker.py diff --git a/.gitignore b/.gitignore index 128639d8..b07c8325 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target/ /bin/ /logs/ +/.local/ /settings/ __pycache__/ *.pyc diff --git a/PLAY-SESSION-MARKER.bat b/PLAY-SESSION-MARKER.bat new file mode 100644 index 00000000..8060b036 --- /dev/null +++ b/PLAY-SESSION-MARKER.bat @@ -0,0 +1,20 @@ +@echo off +setlocal +cd /d "%~dp0" +set "MARKER_DIR=%~dp0.local\session-marker" +if not exist "%MARKER_DIR%\skate3rust.exe" ( + echo Missing private session-marker build. See docs\session-markers.md. + pause + exit /b 1 +) +if not defined SKATE3_ASSETS ( + if exist "%MARKER_DIR%\assets.path" set /p "SKATE3_ASSETS="<"%MARKER_DIR%\assets.path" +) +if not defined SKATE3_ASSETS ( + echo Set SKATE3_ASSETS to your extracted assets directory. + pause + exit /b 1 +) +set "SKATE3_SESSION_MARKER_OVERLAY=%MARKER_DIR%\overlay" +"%MARKER_DIR%\skate3rust.exe" --assets "%SKATE3_ASSETS%" --start-paused %* +if errorlevel 1 pause diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 598aab81..c725c647 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -106,5 +106,6 @@ pub(crate) fn build( verification::VerificationPlugin, crate::performance::PerformancePlugin, )); + app.add_plugins(crate::session_marker::SessionMarkerPlugin); app } diff --git a/crates/skate-game/src/input/controllers.rs b/crates/skate-game/src/input/controllers.rs index ba08ca9e..1985db5d 100644 --- a/crates/skate-game/src/input/controllers.rs +++ b/crates/skate-game/src/input/controllers.rs @@ -52,6 +52,19 @@ impl Default for ControllerInput { } impl ControllerInput { + /// Original input.cfg: LB.held && DPadD.pressed / LB.held && DPadU.held. + /// Use the same debounced native Pad publication as ordinary gameplay. + pub(crate) fn session_marker_actions(&self) -> (bool, bool, bool) { + let Some(device) = self.status.iter().position(|s| *s == ControllerStatus::Ready) else { + return (false, false, false); + }; + let pad = &self.pads[device]; + if pad.count() == 0 { return (false, false, false); } + let flags = |i: usize| pad.records().get(i).map_or(0, |r| r[1]); + let modifier = flags(8) & 0xff00 != 0; + (modifier, modifier && flags(1) & 0xff00_0000 != 0, + modifier && flags(0) & 0xff00 != 0) + } pub(crate) fn raw_input(&self) -> RawInput { self.status.iter().position(|s| *s == ControllerStatus::Ready) .map_or(RawInput::default(), |i| self.raw[i]) diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 334f2f5a..d1f7eec5 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -12,6 +12,7 @@ mod difficulty; mod graph_host; mod graph_runtime; mod input; +mod session_marker; mod physics; mod skater_animation; mod verification; diff --git a/crates/skate-game/src/physics/player_state.rs b/crates/skate-game/src/physics/player_state.rs index 94417bb5..8acaf145 100644 --- a/crates/skate-game/src/physics/player_state.rs +++ b/crates/skate-game/src/physics/player_state.rs @@ -121,7 +121,10 @@ pub(crate) fn enter_after_teleport( physics: &mut GamePhysics, skater: &mut SkaterRuntime, ) -> Result<(), String> { - transition::set(physics, skater, PhysicalStateId::PhysicsGround) + let target = if skater.teleport_state.take_manual_on_board() == Some(false) { + PhysicalStateId::BipedGround + } else { PhysicalStateId::PhysicsGround }; + transition::set(physics, skater, target) } /// Custom authored climbing completes at a validated standing surface. diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs index c2079894..9776bd21 100644 --- a/crates/skate-game/src/physics/teleport_state.rs +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -22,13 +22,22 @@ pub(crate) struct Runtime { state: TeleportState, checkpoint: Checkpoint, pending_reply: Option, + manual_on_board: Option, } impl Runtime { + /// Explicit manual return uses the actor-reset publication without replacing + /// the automatic recovery checkpoint owned by this runtime. + pub fn request_manual(&mut self, transform: AnimationPartTransform, on_board: bool) { + self.pending_reply = Some(Target { transform: transform.map(|r| r.map(f32::to_bits)), on_board }); + self.manual_on_board = Some(on_board); + } + pub fn take_manual_on_board(&mut self) -> Option { self.manual_on_board.take() } pub fn new(checkpoint: Checkpoint) -> Self { Self { state: TeleportState::default(), checkpoint, pending_reply: None, + manual_on_board: None, } } pub fn enter(&mut self) { diff --git a/crates/skate-game/src/session_marker/effect.rs b/crates/skate-game/src/session_marker/effect.rs new file mode 100644 index 00000000..f947898a --- /dev/null +++ b/crates/skate-game/src/session_marker/effect.rs @@ -0,0 +1,126 @@ +//! Native noise pass, composited before the original HUD. +use super::noise::Noise; +use bevy::{ + asset::{RenderAssetUsages, embedded_asset}, + camera::visibility::RenderLayers, + image::{ImageAddressMode, ImageSampler, ImageSamplerDescriptor}, + prelude::*, + render::render_resource::{AsBindGroup, Extent3d, ShaderType, TextureDimension, TextureFormat}, + shader::ShaderRef, + sprite_render::{AlphaMode2d, Material2d, Material2dPlugin}, +}; + +#[derive(Clone, Debug, ShaderType)] +struct Params { + scroll: Vec4, + first: Vec4, + second: Vec4, + fade: Vec4, +} +#[derive(Asset, TypePath, AsBindGroup, Clone, Debug)] +struct Effect { + #[uniform(0)] + params: Params, + #[texture(1)] + #[sampler(2)] + texture: Handle, +} +impl Material2d for Effect { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/session_marker/effect.wgsl".into() + } + fn alpha_mode(&self) -> AlphaMode2d { + AlphaMode2d::Blend + } +} +#[derive(Resource)] +struct Runtime { + noise: Noise, + material: Handle, + time: f64, +} +#[derive(Component)] +struct Screen; + +pub(super) fn install(app: &mut App) { + embedded_asset!(app, "effect.wgsl"); + app.add_plugins(Material2dPlugin::::default()) + .add_systems(Startup, load) + .add_systems(Update, present); +} +fn load( + mut commands: Commands, + mut images: ResMut>, + mut materials: ResMut>, + mut meshes: ResMut>, +) { + let mut noise = Noise::default(); + let mut image = Image::new( + Extent3d { + width: 64, + height: 64, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + noise.texture(), + TextureFormat::Rgba8Unorm, + RenderAssetUsages::default(), + ); + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + ..ImageSamplerDescriptor::linear() + }); + let material = materials.add(Effect { + texture: images.add(image), + params: Params { + scroll: Vec4::ZERO, + first: Vec4::X, + second: Vec4::X, + fade: Vec4::ZERO, + }, + }); + commands.spawn(( + Screen, + Mesh2d(meshes.add(Rectangle::new(1., 1.))), + MeshMaterial2d(material.clone()), + Transform::from_xyz(0., 0., -1.), + Visibility::Hidden, + RenderLayers::layer(29), + )); + commands.insert_resource(Runtime { + noise, + material, + time: 0., + }); +} +fn present( + mut runtime: ResMut, + session: Res, + time: Res>, + window: Single<&Window>, + mut materials: ResMut>, + mut screen: Query<(&mut Transform, &mut Visibility), With>, +) { + runtime.time += time.delta_secs_f64(); + while runtime.time >= 1. / 60. { + runtime.time -= 1. / 60.; + runtime.noise.advance(); + } + if let Some(material) = materials.get_mut(&runtime.material) { + material.params = Params { + scroll: Vec4::from_array(runtime.noise.scroll), + first: Vec4::from_array(Noise::weights(runtime.noise.phases[0])), + second: Vec4::from_array(Noise::weights(runtime.noise.phases[1])), + fade: Vec4::new(session.progress, 0., 0., 0.), + }; + } + for (mut transform, mut visibility) in &mut screen { + transform.scale = Vec3::new(window.width(), window.height(), 1.); + *visibility = if session.progress > 0. { + Visibility::Inherited + } else { + Visibility::Hidden + }; + } +} diff --git a/crates/skate-game/src/session_marker/effect.wgsl b/crates/skate-game/src/session_marker/effect.wgsl new file mode 100644 index 00000000..a8797821 --- /dev/null +++ b/crates/skate-game/src/session_marker/effect.wgsl @@ -0,0 +1,17 @@ +#import bevy_sprite::mesh2d_vertex_output::VertexOutput + +struct Params { scroll: vec4, first: vec4, second: vec4, fade: vec4 }; +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var params: Params; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var noise_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var noise_sampler: sampler; + +// Noise contribution of postfx_basictex_fisheye_noisePS, instructions3..13, +//17..18,27..28. Scene blending uses native f_NoiseFade=(1-p,p,0,0). +@fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { + let first = textureSample(noise_texture, noise_sampler, + in.uv * vec2(10., 5.625) + params.scroll.xy); + let second = textureSample(noise_texture, noise_sampler, + in.uv * vec2(11., 6.625) + params.scroll.zw); + let value = clamp(2. * (dot(first, params.first) + dot(second, params.second)) - 2., 0., 1.); + return vec4(vec3(value), params.fade.x); +} diff --git a/crates/skate-game/src/session_marker/hud.rs b/crates/skate-game/src/session_marker/hud.rs new file mode 100644 index 00000000..6d1d9d74 --- /dev/null +++ b/crates/skate-game/src/session_marker/hud.rs @@ -0,0 +1,177 @@ +//! Original APT/GEO triangles, Xbox prompts and bitmap glyphs; no system fonts. +use bevy::{ + asset::RenderAssetUsages, + camera::visibility::RenderLayers, + image::ImageSampler, + prelude::*, + render::render_resource::{Extent3d, PrimitiveTopology, TextureDimension, TextureFormat}, +}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Deserialize)] +struct Hud { + version: u32, + canvas: [f32; 2], + textures: Vec, + meshes: Vec, +} +#[derive(Deserialize)] +struct Texture { + file: String, + width: u32, + height: u32, +} +#[derive(Deserialize)] +struct Primitive { + texture: Option, + color: [f32; 4], + vertices: Vec, +} +#[derive(Deserialize)] +struct Vertex { + position: [f32; 2], + uv: [f32; 2], +} +#[derive(Component)] +struct MarkerHudRoot; + +pub(super) fn install(app: &mut App) { + app.add_systems(Startup, load).add_systems(Update, present); +} + +pub(super) fn overlay_path(root: &Path) -> PathBuf { + std::env::var_os("SKATE3_SESSION_MARKER_OVERLAY") + .map(PathBuf::from) + .unwrap_or_else(|| root.join("private/session-marker")) +} + +fn load( + mut commands: Commands, + config: Res, + mut meshes: ResMut>, + mut materials: ResMut>, + mut images: ResMut>, +) { + let layer = RenderLayers::layer(29); + commands.spawn(( + Camera2d, + Camera { + order: 1, + clear_color: ClearColorConfig::None, + ..default() + }, + Msaa::Off, + layer.clone(), + )); + let folder = overlay_path(&config.asset_root); + let read = || -> Result { + let bytes = std::fs::read(folder.join("hud.json")).map_err(|e| e.to_string())?; + let hud: Hud = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if hud.version != 1 || hud.canvas != [1280., 720.] { + return Err("Unsupported original HUD manifest".into()); + } + Ok(hud) + }; + let hud = match read() { + Ok(h) => h, + Err(e) => { + warn!( + "Original session-marker HUD unavailable at {}: {e}", + folder.display() + ); + return; + } + }; + let mut textures = Vec::new(); + for t in hud.textures { + let path = Path::new(&t.file); + if path.components().count() != 1 || t.width == 0 || t.height == 0 { + error!("Invalid marker texture descriptor"); + return; + } + let Ok(bytes) = std::fs::read(folder.join(path)) else { + error!("Missing marker texture {}", t.file); + return; + }; + if bytes.len() != t.width as usize * t.height as usize * 4 { + error!("Invalid marker texture length"); + return; + } + let mut image = Image::new( + Extent3d { + width: t.width, + height: t.height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + bytes, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ); + image.sampler = ImageSampler::linear(); + textures.push(images.add(image)); + } + if hud.meshes.iter().any(|m| { + m.vertices.len() % 3 != 0 + || m.texture.is_some_and(|t| t >= textures.len()) + || !m + .vertices + .iter() + .all(|v| v.position.iter().chain(&v.uv).all(|x| x.is_finite())) + }) { + error!("Invalid marker HUD geometry"); + return; + } + commands + .spawn((MarkerHudRoot, Transform::default(), Visibility::Hidden)) + .with_children(|parent| { + for (order, primitive) in hud.meshes.into_iter().enumerate() { + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_POSITION, + primitive + .vertices + .iter() + .map(|v| [v.position[0], -v.position[1], 0.]) + .collect::>(), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_UV_0, + primitive.vertices.iter().map(|v| v.uv).collect::>(), + ); + let [r, g, b, a] = primitive.color; + let material = ColorMaterial { + color: Color::srgba(r, g, b, a), + texture: primitive.texture.map(|i| textures[i].clone()), + ..default() + }; + parent.spawn(( + Mesh2d(meshes.add(mesh)), + MeshMaterial2d(materials.add(material)), + layer.clone(), + Transform::from_xyz(0., 0., order as f32 * 0.01), + )); + } + }); +} + +fn present( + session: Res, + window: Single<&Window>, + mut roots: Query<(&mut Transform, &mut Visibility), With>, +) { + let scale = (window.width() / 1280.).min(window.height() / 720.); + for (mut transform, mut visibility) in &mut roots { + *visibility = if session.visible { + Visibility::Inherited + } else { + Visibility::Hidden + }; + transform.translation = Vec3::new(-window.width() / 2., window.height() / 2., 0.); + transform.scale = Vec3::splat(scale); + } +} diff --git a/crates/skate-game/src/session_marker/mod.rs b/crates/skate-game/src/session_marker/mod.rs new file mode 100644 index 00000000..c76cc520 --- /dev/null +++ b/crates/skate-game/src/session_marker/mod.rs @@ -0,0 +1,204 @@ +//! Manual session markers. Automatic bail recovery owns a different checkpoint. +mod effect; +mod hud; +mod noise; +mod state; +mod validation; +use crate::{ + app::SimulationSet, + input::ControllerInput, + map_transition::CurrentMap, + physics::{GamePhysics, SkaterRuntime}, +}; +use bevy::prelude::*; +use skate_core::physics::{board::BodyId, skeleton_animation_record::AnimationPartTransform}; + +#[derive(Clone, Copy, Debug)] +struct Marker { + transform: AnimationPartTransform, + on_board: bool, + foot_forward: bool, + generation: u64, +} + +/// Audio owners consume these native GlobalFEPlaySound event IDs. +#[derive(Message, Clone, Copy, Debug)] +pub(crate) struct SessionMarkerAudio(pub u64); + +#[derive(Resource, Default)] +pub(crate) struct SessionMarker { + marker: Option, + hold: state::Hold, + generation: u64, + pub visible: bool, + pub can_place: bool, + pub can_return: bool, + pub progress: f32, + blocked_until_release: bool, + ui_time: f64, + last_batch: u64, +} + +pub(crate) struct SessionMarkerPlugin; +impl Plugin for SessionMarkerPlugin { + fn build(&self, app: &mut App) { + let root = &app.world().resource::().asset_root; + match validation::Validation::load(root) { + Ok(settings) => { + app.insert_resource(settings); + } + Err(e) => { + error!("Session marker validation data: {e}"); + return; + } + } + app.init_resource::() + .add_message::() + .add_systems( + PreUpdate, + suspend.after(crate::map_transition::MapTransitionSet), + ) + .add_systems( + FixedUpdate, + update + .after(SimulationSet::Input) + .before(SimulationSet::Controls) + .run_if(crate::graphics_menu::gameplay_active), + ); + hud::install(app); + effect::install(app); + } +} + +fn suspend( + mut marker: ResMut, + map: Res, + menu: Res, + replay: Res, +) { + if marker.generation != map.generation { + *marker = SessionMarker { + generation: map.generation, + blocked_until_release: true, + ..default() + }; + } + if !crate::graphics_menu::gameplay_active(Some(menu)) || replay.active { + marker.hold.cancel(); + marker.visible = false; + marker.progress = 0.; + marker.blocked_until_release = true; + marker.ui_time = 0.; + } +} + +fn update( + mut session: ResMut, + input: Res, + map: Res, + physics: Res, + mut skater: ResMut, + validation: Res, + replay: Res, + time: Res>, + mut audio: MessageWriter, +) { + if replay.active { + return; + } + let (modifier, set, held) = input.session_marker_actions(); + if session.blocked_until_release { + if !modifier { + session.blocked_until_release = false; + } + return; + } + let p = &skater.player_input.physical; + let on_board = p.state.category_12 != 500; + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let mut transform = skater.animated_skeleton.roots.animation_to_world; + if on_board { + for i in 0..3 { + transform[i][..3].copy_from_slice(&deck.basis.columns[i]); + } + transform[3] = [ + deck.translation.x, + deck.translation.y + 0.2, + deck.translation.z, + 0., + ]; + //82591E30: above .5m/s, project normalized velocity onto world Up. + //The cross products are deliberately not normalized a second time. + let velocity = Vec3::from_slice(&p.skateboard.vector_80.map(f32::from_bits)[..3]); + if velocity.length_squared() > 0.25 { + let right = Vec3::Y.cross(velocity.normalize()); + let forward = right.cross(Vec3::Y); + if forward.length_squared() > 0.9 { + transform[0] = right.extend(0.).to_array(); + transform[1] = Vec3::Y.extend(0.).to_array(); + transform[2] = forward.extend(0.).to_array(); + } + } + } + let state = p.state.state_16; + let state_allowed = (p.state.category_12 == 100 + && p.collision.wheel_count_0 >= 2 + && state != 104 + && deck.basis.columns[1][1] > 0.71) + || (p.state.category_12 == 500 && state == 500); + session.can_place = modifier + && state_allowed + && p.surface_default_mode != 8 + && validation.check(physics.world(), transform[3]); + session.can_return = session + .marker + .is_some_and(|m| m.generation == map.generation) + && !matches!(state, 104 | 502); + session.visible = modifier; + // A retained Pad publication must not turn one .pressed into repeated sets. + if set && session.last_batch != input.consumed_batches { + if session.can_place { + session.marker = Some(Marker { + transform, + on_board, + foot_forward: skater.animation.foot_forward(), + generation: map.generation, + }); + audio.write(SessionMarkerAudio(0x0d6c_88a3_b91c_828f)); + } else { + audio.write(SessionMarkerAudio(0x66b3_afe3_b602_918c)); + } + } + session.last_batch = input.consumed_batches; + let ready = p.state.flag_69 == 0 + && state != 702 + && !(physics.board_wiping_out && p.skeleton.teleport_pending_604 != 0) + && skater.player_input.pending_teleport().is_none(); + let distance = session.marker.map_or(0., |m| { + Vec3::from_slice(&transform[3][..3]).distance(Vec3::from_slice(&m.transform[3][..3])) + }); + let usable = session.can_return; + session.ui_time += time.delta_secs_f64(); + while session.ui_time >= 1. / 60. { + session.ui_time -= 1. / 60.; + let step = session.hold.update(held, usable, distance, ready); + session.progress = step.progress; + if step.relocate { + if let Some(target) = session.marker { + match skater.player_input.request_teleport(target.transform) { + Ok(()) => { + skater.animation.restore_foot_forward(target.foot_forward); + skater + .teleport_state + .request_manual(target.transform, target.on_board); + audio.write(SessionMarkerAudio(0x7f13_5f9f_d28f_7f21)); + } + Err(e) => { + warn!("Session marker return rejected: {e}"); + session.hold.cancel(); + } + } + } + } + } +} diff --git a/crates/skate-game/src/session_marker/noise.rs b/crates/skate-game/src/session_marker/noise.rs new file mode 100644 index 00000000..4992d17d --- /dev/null +++ b/crates/skate-game/src/session_marker/noise.rs @@ -0,0 +1,64 @@ +//! Native six-word generator82A8AF10 and noise texture827ED7E8. +//! Independent stream: the original shares this generator with other render work. +pub(super) struct Noise { + words: [u32; 6], + pub phases: [f32; 2], + pub scroll: [f32; 4], +} +impl Default for Noise { + fn default() -> Self { + Self { + words: [ + 4063039062, 2284922601, 3324304687, 117621916, 2654289789, 1876900708, + ], + phases: [0.; 2], + scroll: [0.; 4], + } + } +} +impl Noise { + fn next(&mut self) -> u32 { + let mut carry = 0u64; + for i in (0..5).rev() { + let sum = self.words[i] as u64 + self.words[i + 1] as u64 + carry; + self.words[i] = sum as u32; + carry = sum >> 32; + } + for i in (0..6).rev() { + self.words[i] = self.words[i].wrapping_add(1); + if self.words[i] != 0 { + break; + } + } + self.words[0] + } + pub fn texture(&mut self) -> Vec { + let mut bytes = Vec::with_capacity(64 * 64 * 4); + for _ in 0..64 * 64 * 4 { + bytes.push(if (self.next() >> 8) & 255 > 128 { + 255 + } else { + 0 + }); + } + bytes + } + pub fn advance(&mut self) { + for (phase, delta) in self.phases.iter_mut().zip([0.045f32, 0.06]) { + *phase += delta; + if *phase >= 4. { + *phase -= 4.; + } + } + //827EE03C calls four times, then writes fourth, third, second, first. + self.scroll = std::array::from_fn(|_| (self.next() & 65535) as f32 / 65536.); + self.scroll.reverse(); + } + pub fn weights(phase: f32) -> [f32; 4] { + let mut weights = [0.; 4]; + let index = phase as usize; + weights[index % 4] = 1. - phase.fract(); + weights[(index + 1) % 4] = phase.fract(); + weights + } +} diff --git a/crates/skate-game/src/session_marker/state.rs b/crates/skate-game/src/session_marker/state.rs new file mode 100644 index 00000000..51afed67 --- /dev/null +++ b/crates/skate-game/src/session_marker/state.rs @@ -0,0 +1,98 @@ +//! PlayerUI::UpdateSessionMarker 82898FC8. Time advances in native UI ticks. +#[derive(Default, Debug)] +pub(super) struct Hold { + elapsed: f32, + fired: bool, + tail: u8, +} + +#[derive(Default, Debug, PartialEq)] +pub(super) struct Step { + pub progress: f32, + pub relocate: bool, +} + +impl Hold { + pub fn cancel(&mut self) { + *self = Self::default(); + } + + pub fn update(&mut self, held: bool, usable: bool, distance: f32, ready: bool) -> Step { + let mut out = Step::default(); + if !held { + self.elapsed = 0.; + self.fired = false; + } else if !self.fired && usable { + self.elapsed += f32::from_bits(0x3c88_8889); + //828992C8: within half a metre there is no effect or relocation. + if distance > 0.5 && distance.is_finite() { + let duration = duration(distance); + if ready && self.elapsed > duration { + self.fired = true; + self.tail = 3; + out.relocate = true; + } + out.progress = (self.elapsed / duration).clamp(0., 1.); + } + } + //828994F0 also executes in the relocation tick. + if self.tail > 0 { + out.progress = 1.; + self.tail -= 1; + } + out + } +} + +pub(super) fn duration(distance: f32) -> f32 { + if distance <= 100. { + 0.2 + } else if distance >= 1000. { + 1. + } else { + distance.mul_add(f32::from_bits(0x3a69_0453), f32::from_bits(0x3de3_8e39)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn release_cancels_and_completed_hold_does_not_repeat() { + let mut hold = Hold::default(); + for _ in 0..6 { + assert!(!hold.update(true, true, 20., true).relocate); + } + assert_eq!(hold.update(false, true, 20., true), Step::default()); + for _ in 0..11 { + assert!(!hold.update(true, true, 20., true).relocate); + } + let mut count = 0; + for _ in 0..120 { + count += usize::from(hold.update(true, true, 20., true).relocate); + } + assert_eq!(count, 1); + assert_eq!(hold.update(true, true, 20., true).progress, 0.); + } + #[test] + fn unset_nearby_and_blocked_returns() { + let mut hold = Hold::default(); + for _ in 0..100 { + assert_eq!(hold.update(true, false, 10., true), Step::default()); + } + for _ in 0..100 { + assert_eq!(hold.update(true, true, 0.5, true), Step::default()); + } + assert!(!hold.update(true, true, 10., false).relocate); + assert!(hold.update(true, true, 10., true).relocate); + assert_eq!(hold.update(false, true, 10., true).progress, 1.); + assert_eq!(hold.update(false, true, 10., true).progress, 1.); + assert_eq!(hold.update(false, true, 10., true).progress, 0.); + } + #[test] + fn distance_ramp_matches_native_endpoints() { + assert_eq!(duration(100.), 0.2); + assert!((duration(550.) - 0.6).abs() < 0.000001); + assert_eq!(duration(1000.), 1.); + } +} diff --git a/crates/skate-game/src/session_marker/validation.rs b/crates/skate-game/src/session_marker/validation.rs new file mode 100644 index 00000000..c9d3b02f --- /dev/null +++ b/crates/skate-game/src/session_marker/validation.rs @@ -0,0 +1,48 @@ +//! 8289B928 actor eligibility and 82BFBC18 checkpoint geometry checks. +use bevy::prelude::*; +use skate_core::{math::Vector3, physics::board_world::BoardWorld}; +use skate_data::collections::Collections; + +#[derive(Resource)] +pub(super) struct Validation { + slope: f32, + max_drop: f32, + clearance_length: f32, + clearance_radius: f32, +} +impl Validation { + pub fn load(root: &std::path::Path) -> Result { + let c = Collections::load(root)?; + let f = |name| c.float("Hash_12B64C0E804B0853", "default", name); + Ok(Self { + slope: f("Hash_ADD032CACF6A1C15")?, + max_drop: f("Hash_8ABE098D3806D273")?, + clearance_length: f("Hash_C0526C883AF0ECCA")?, + clearance_radius: f("Hash_CEB092E418A5B001")?, + }) + } + pub fn check(&self, world: &BoardWorld, position: [f32; 4]) -> bool { + if !position.iter().all(|v| v.is_finite()) { + return false; + } + let start = Vector3::new(position[0], position[1] + 0.1, position[2]); + let end = Vector3::new(start.x, start.y - 10., start.z); + let Ok(Some(hit)) = world.query_thin_line(start, end) else { + return false; + }; + //82BFBE30: CTR jump chain, including the zero-case fallthrough. + let surface = (hit.tag >> 7) & 31; + if hit.geometry.normal.y < self.slope + || start.y - hit.geometry.position.y > self.max_drop + || matches!(surface, 5 | 6 | 9 | 12 | 13) + { + return false; + } + let lower = Vector3::new(start.x, start.y + self.clearance_radius, start.z); + let upper = Vector3::new(lower.x, lower.y + self.clearance_length, lower.z); + matches!( + world.query_swept_line(lower, upper, self.clearance_radius), + Ok(None) + ) + } +} diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index 86c79630..139ec110 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -77,6 +77,20 @@ pub(crate) struct SkaterAnimation { } impl SkaterAnimation { + ///Actor82592B68: interface32/82B97388, inverted by interface12's fakie bit. + pub fn foot_forward(&self) -> bool { + let p = &self.state.publication; + (p.natural_stance == p.relative_stance) ^ self.state.fakie() + } + ///Actor82592C08 ->82B97350 queues the stance applied by82B97308/82B972A8. + ///Only orientation/mirror and relative stance change; natural stance survives. + pub fn restore_foot_forward(&mut self, forward: bool) { + self.state.flags = (self.state.flags & 0x3fff_ffff) + | if forward { 0xc000_0000 } else { 0 }; + let natural = self.state.publication.natural_stance; + self.state.publication.relative_stance = if forward { natural } else { 1 - natural }; + self.motion.animation.skater_animation_flags = Some(self.state.flags); + } pub fn stance(&self) -> (bool, bool) { (self.state.fakie(), self.state.mirrored()) } diff --git a/docs/session-markers.md b/docs/session-markers.md new file mode 100644 index 00000000..a9a416f5 --- /dev/null +++ b/docs/session-markers.md @@ -0,0 +1,118 @@ +# Native session markers + +`PLAY-SESSION-MARKER.bat` runs this task's private executable and original HUD +overlay, starting paused. Resume, hold **LB**, press **D-pad Down** to place; +hold **LB + D-pad Up** to return. These are the original `input.cfg` bindings +(`SessionMarkerSet`, `SessionMarkerUse`), including pressed versus held behavior. +An unset marker cannot return. Releasing cancels the hold. A completed hold does +not repeatedly teleport until released. Automatic bail recovery retains its own +checkpoint. Successful map changes clear the manual marker; pause and replay +cancel an active return and require releasing LB before accepting another one. + +The branch includes map-transition baseline `4a92ad1`, cherry-picked as `1d89d85`. +No game, recomp, controller harness or GPU gameplay validation was launched for +this task. The launcher is for the user's manual validation. + +## Original evidence + +Addresses are TU3 virtual addresses in the owned `default.xex` image, with the +local disassembly image based at `0x82000000`. Local generated PPC and the +community symbol map corroborate function boundaries. No game binary, extracted +art, original shader bytecode, fonts or private research files are committed. + +SHA-256 provenance: + +```text +Owned default.xex: 1db39496585c521d17a2137804f42cf73ebed2b32cac166ec42dbf772f4dcf7f +TU3 disassembly image: f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4 +Private manual executable: 38be66b784b64d87d2134120b9bf582c74b295d4a01b1d1e9246cf62b4f0775f +``` + +| Native owner | Behavior represented | +| --- | --- | +| `82898FC8` PlayerUI::UpdateSessionMarker | Actions42/43; set/return ordering; retained timer and release latch | +| `8289B6D8`, `8289B140` | Saved transform, foot-forward and on-board byte; marker initially unset | +| `8289B928`, `8289BAE0` | Ground/biped eligibility; wheel count, deck Up.Y threshold, excluded states | +| `82BFBC18` | Downward10m line from Y+.1; VLT slope/drop/clearance; surface switch | +| `82591E30` | Deck+.2 capture; normalized travel velocity above squared speed.25; cross-product threshold.9 | +| `82592B68`, `82B97388`, `82B970D8` | Foot-forward is natural/relative stance equality, inverted by fakie | +| `82592C08`, `82B97350`, `82B97308`, `82B972A8` | Foot-forward setter and orientation/mirror/relative-stance publication | +| `828977A8` | Flag69, pending wipeout teleport and state702 return gates | +| `825926F8`, `82DB8998` | Actor-reset packet and ordinary board/skeleton/velocity reset; camera cut publication | +| `827A9C60`, `827AAF10`, `827AB790` | `cMsgTeleportEffectAmount` (`FAF37802`) publication | +| `827EDE58` | Progress to `f_NoiseFade` (`F0F1D438`), `(1-p,p,0,0)`; random UV scroll and channel weights | +| `827ED7E8`, `82A8AF10` |64x64 four-channel binary noise texture, native six-word generator | + +Return duration is.2s through100m,1s from1000m, and a native fused linear ramp +between them (`3A690453`, `3DE38E39`). The timer adds `3C888889` per UI tick. +Relocation uses a strict greater-than comparison. Within.5m there is no effect +or relocation; the native timer still accumulates. The trigger tick and next two +UI ticks publish full effect. The shader's recovered noise contribution uses +the original10x5.625 and11x6.625 UV scales and channel interpolation, rather than +a generic random-pixel or scanline effect. + +The validation collection is `Hash_12B64C0E804B0853/default`, with fields +`Hash_ADD032CACF6A1C15` (slope.75), `Hash_8ABE098D3806D273` (drop1m), +`Hash_C0526C883AF0ECCA` (sweep length.4), and +`Hash_CEB092E418A5B001` (radius.5). Values load from installed VLT data. +The CTR branch chain at `82BFBE30`, including its zero-case fallthrough, rejects +surface categories5,6,9,12,13; category8 is allowed by this geometry function. + +## Original HUD extraction and future integration + +`tools/extract_session_marker.py` uses the existing private UI toolkit. Example: + +```powershell +python tools/extract_session_marker.py --game OWNER_GAME_DIRECTORY --ui-toolkit UI_TOOLKIT_DIRECTORY --output .local/session-marker/overlay +``` + +The toolkit directory contains the `skate3_ui_extract` Python package. Its +existing APT/GEO/RX2/font readers perform extraction; this adapter compiles only +the marker subtree. Re-running updates its source cache incrementally. + +Sources: `hud2/hudphonelist`, imported `controls/button_item2`, the Xbox360 +`button_DPad_Up_hud.Texture` and `button_DPad_Down_hud.Texture`, original Futura +Shadow atlas/metrics and English language labels. The compiler emits ten +triangle meshes and five private RGBA textures. The root `hudintro` endpoint14, +quick-menu `maximized` endpoint49 and two-item label endpoint8 supply the actual +placements. No replacement typeface or redrawn panel is used. Source texture +and extraction-manifest hashes remain in the private compiled manifest. + +Runtime accepts `SKATE3_SESSION_MARKER_OVERLAY`, falling back to +`ASSET_ROOT/private/session-marker`. This is the narrow interface for a future +shared HUD extraction/setup owner: emit `hud.json` schema1 and its referenced +RGBA files there. This task does not install another whole frontend pipeline. +The launcher uses `.local/session-marker/assets.path`, or `SKATE3_ASSETS`. + +Audio integration emits `SessionMarkerAudio(u64)` with original GlobalFEPlaySound +IDs: place `0D6C88A3B91C828F`, rejected place `66B3AFE3B602918C`, return +`7F135F9FD28F7F21`. There is currently no audio consumer in this implementation. + +## Verification and limits + +Release compilation passed with the existing static-CRT Windows target flags: + +```text +cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --no-default-features +``` + +Three standalone hold-state tests passed (cancellation/latching, unset/nearby/ +blocked returns and tail, distance ramp). The WGSL passed offline Naga27 parsing +and validation. Original HUD manifest counts, triangle structure and every RGBA +payload size passed data checks. These checks initialize no game or GPU device. + +Manual validation remains necessary: place/replace, return from riding and +biped states, switch/fakie stance, interrupted holds, bail recovery, camera cuts, +replay/pause and successful/failed map changes. No gameplay result is claimed. + +This is not a claim of complete frontend/render parity. The native region +membership service (`82BFBB48`), challenge/online mode restrictions and disabled +row ActionScript alpha behavior have no corresponding owner here yet. The HUD +uses the recovered timeline endpoints, not a general ActionScript runtime. +The noise stream starts from the original static generator words but is isolated +from unrelated original renderer callers, so its exact pixel sequence differs. +The compositor implements the marker noise contribution; general TV/fisheye and +vignette passes are outside this feature. Native render scheduling/color-space +equivalence and stance/reset timing still need manual observation. Map marker +invalidation is a deliberate host lifecycle rule, not a recovered serialization +claim. These gaps are explicit integration work, not synthetic substitutes. diff --git a/tools/extract_session_marker.py b/tools/extract_session_marker.py new file mode 100644 index 00000000..38eb03c3 --- /dev/null +++ b/tools/extract_session_marker.py @@ -0,0 +1,131 @@ +"""Incrementally extract the owned session-marker HUD into a private overlay. + +Uses the existing UI toolkit's bounds-checked APT/GEO/RX2/font readers. No game +process, captures, font substitution or redrawn artwork is involved. +""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +def compile_hud(cache_root: Path, output: Path) -> None: + from skate3_ui_extract.scene_graph import AssetCache, SceneFlattener, transform_point + from skate3_ui_extract.bitmap_font import measure_bitmap_text + from skate3_ui_extract.timeline import playback_frame + cache = AssetCache(cache_root) + bundle = "data/fe/source/screens/hud2/hudphonelist" + labels = json.loads((cache_root / "metadata/languages/english_global.json").read_text()) + language = {e["label"]: e["value"] for e in labels["entries"] if "label" in e} + + def state(path, owner, character): + if path == "/mSkatePark": return {"visible": False} + if character["type_name"] in ("sprite", "animation"): + count = character["movie"]["frame_count"] + if owner["key"] == bundle: + label = {0: "hudintro", 17: "maximized", 16: "2"}.get(character["id"]) + if label: return {"frame": playback_frame(character, label, play=True)} + if count <= 1: return {"frame": 0} + return {} + + flat = SceneFlattener(cache, state).flatten(bundle, 0) + if flat["unresolved"]: raise ValueError(flat["unresolved"]) + output.mkdir(parents=True, exist_ok=True) + textures = [] + texture_ids = {} + + def texture(path, width=None, height=None): + path = str(path) + if path in texture_ids: return texture_ids[path] + from PIL import Image + src = cache_root / path + with Image.open(src) as im: + im = im.convert("RGBA") + width, height = im.size + index = len(textures) + name = f"texture-{index}.rgba" + (output / name).write_bytes(im.tobytes()) + texture_ids[path] = index + textures.append({"file": name, "width": width, "height": height, + "source": path, "sha256": hashlib.sha256(src.read_bytes()).hexdigest()}) + return index + + buttons_dir = Path("assets/data/fe/source/images/buttons/xbox360/buttons") + buttons = json.loads((cache_root / buttons_dir / "manifest.json").read_text())["textures"] + meshes = [] + for primitive in flat["primitives"]: + tex = primitive.get("texture") + index = texture(tex["preview"]) if tex else None + role = "art" + if "mButtonRender" in primitive["path"]: + role = "return" if "/mButton0/" in primitive["path"] else "place" + name = "button_DPad_Up_hud.Texture" if role == "return" else "button_DPad_Down_hud.Texture" + item = next(t for t in buttons if t["name"] == name) + index = texture(buttons_dir / item["preview_file"]) + # SetButtonType replaces the authored 32px button render rectangle. + # Preserve its original geometry/placement and bind the retail icon. + inv = primitive["matrix"] + for triangle in primitive["triangles"]: + for vertex in triangle: + x, y = vertex["position"] + vertex["uv"] = [(x - inv[4] + 16.5) / 32, (y - inv[5] + 16.5) / 32] + primitive["color"] = [1, 1, 1, primitive["color"][3]] + meshes.append({"texture": index, "role": role, "color": primitive["color"], + "vertices": [dict(position=v["position"], uv=v.get("uv", [0, 0])) + for tri in primitive["triangles"] for v in tri], + "order": primitive["draw_order"], "source": primitive["path"]}) + for text in flat["text"]: + value = language.get(text["value"], text["value"]) + if value.startswith("ID_"): raise ValueError(f"Missing retail text {value}") + font = text["font_asset"] + definition = font["definition"] + metrics = measure_bitmap_text(definition, value, text["font_height"]) + index = texture(font["preview"]) + atlas = textures[index] + glyphs = {g["glyph_index"]: g for g in definition["glyphs"]} + vertices = [] + scale = metrics["scale"] + for used in metrics["glyphs"]: + g = glyphs[used["glyph_index"]] + x = text["bounds"][0] + 2 + used["left"] + y = text["bounds"][1] + 2 + text["font_height"] - g["y_offset"] * scale + w, h = g["width"] * scale, g["height"] * scale + a, b, c, d = g["atlas_bounds"] + corners = [(x,y,a,b), (x+w,y,c,b), (x+w,y+h,c,d), (x,y+h,a,d)] + for corner in (0,1,2,0,2,3): + px,py,u,v = corners[corner] + vertices.append({"position": transform_point(text["matrix"], [px,py]), + "uv": [u/atlas["width"],v/atlas["height"]]}) + argb = int(text["color_argb"].lstrip("#"), 16) + meshes.append({"texture": index, "role": "return" if "/text0/" in text["path"] else "place", + "color": [((argb >> n) & 255)/255 for n in (16,8,0)] + [text["alpha"]*((argb>>24)&255)/255], + "vertices": vertices, "order": text["draw_order"], "source": text["path"]}) + manifest = {"version": 1, "canvas": [1280,720], "textures": textures, + "meshes": sorted(meshes, key=lambda m:m["order"]), + "source_manifest_sha256": hashlib.sha256((cache_root/"manifest.json").read_bytes()).hexdigest(), + "timelines": {"hudintro": [1,14], "hudoutro": [15,30], "maximized": [27,49]}, + "notes": "Authored display lists; two session-marker rows; dynamic button textures resolved by native SetButtonType names."} + (output/"hud.json").write_text(json.dumps(manifest, indent=2)+"\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--game", type=Path, required=True) + parser.add_argument("--ui-toolkit", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.output.resolve().is_relative_to(args.game.resolve()): + parser.error("Output must be outside the owned game source") + sys.path.insert(0, str(args.ui_toolkit.resolve())) + from skate3_ui_extract.project import extract_project + cache = args.output / "source-cache" + extract_project(args.game, cache, include_dynamic=True, prefixes=( + "data/fe/source/screens/hud2/hudphonelist", "data/fe/source/controls/button_item2", + "data/fe/source/images/buttons/xbox360"), update=True) + compile_hud(cache, args.output) + print(f"Private original session-marker HUD: {args.output / 'hud.json'}") + + +if __name__ == "__main__": main() From 19aeff9bd520dcdc5b88783c960b793eb8751e8f Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:51:47 +0100 Subject: [PATCH 3/5] Stage session-marker executable directly from task-specific linker output --- Build-SessionMarker.ps1 | 39 +++++++++++++++++++++++++++++++++++++++ PLAY-SESSION-MARKER.bat | 4 ++-- docs/session-markers.md | 11 ++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 Build-SessionMarker.ps1 diff --git a/Build-SessionMarker.ps1 b/Build-SessionMarker.ps1 new file mode 100644 index 00000000..f58fd89b --- /dev/null +++ b/Build-SessionMarker.ps1 @@ -0,0 +1,39 @@ +param([switch]$StageOnly, [string]$DependencyTargetDirectory = (Join-Path $PSScriptRoot 'target')) +$ErrorActionPreference = 'Stop' +$privateDirectory = Join-Path $PSScriptRoot '.local/session-marker' +$buildDirectory = Join-Path $privateDirectory 'build' +$artifact = Join-Path $buildDirectory 'skate3rust-session-marker.exe' +$destination = Join-Path $privateDirectory 'skate3-session-marker.exe' +Push-Location $PSScriptRoot +$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS +try { + New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null + if (-not $StageOnly) { + $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static' + # Dependencies may use a shared cache. The linker writes this task's + # artifact directly, never staging the cache's generic skate3rust.exe. + & cargo rustc --release --locked --target x86_64-pc-windows-msvc ` + --target-dir $DependencyTargetDirectory -p skate-game --no-default-features ` + --bin skate3rust -- -C extra-filename=-session-marker ` + -o (Join-Path $buildDirectory 'skate3rust.exe') + if ($LASTEXITCODE -ne 0) { throw 'Session-marker build failed.' } + } + $bytes = [System.IO.File]::ReadAllBytes($artifact) + $text = [System.Text.Encoding]::ASCII.GetString($bytes) + foreach ($required in @('--start-paused', 'session_marker/effect.wgsl', 'Session marker return rejected')) { + if (-not $text.Contains($required)) { throw "Wrong session-marker artifact: missing $required" } + } + Copy-Item -LiteralPath $artifact -Destination $destination -Force + $hash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash + if ($hash -ne (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash) { + throw 'Staged executable hash mismatch.' + } + @{ executable = $destination; sha256 = $hash; source = $PSScriptRoot; + revision = (& git rev-parse HEAD); staticChecks = 'CLI, marker shader, return path' } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $privateDirectory 'build-manifest.json') -Encoding UTF8 + Write-Host "Ready for manual launch: $destination" + Write-Host "SHA256: $hash" +} finally { + $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = $previousFlags + Pop-Location +} diff --git a/PLAY-SESSION-MARKER.bat b/PLAY-SESSION-MARKER.bat index 8060b036..728d57dc 100644 --- a/PLAY-SESSION-MARKER.bat +++ b/PLAY-SESSION-MARKER.bat @@ -2,7 +2,7 @@ setlocal cd /d "%~dp0" set "MARKER_DIR=%~dp0.local\session-marker" -if not exist "%MARKER_DIR%\skate3rust.exe" ( +if not exist "%MARKER_DIR%\skate3-session-marker.exe" ( echo Missing private session-marker build. See docs\session-markers.md. pause exit /b 1 @@ -16,5 +16,5 @@ if not defined SKATE3_ASSETS ( exit /b 1 ) set "SKATE3_SESSION_MARKER_OVERLAY=%MARKER_DIR%\overlay" -"%MARKER_DIR%\skate3rust.exe" --assets "%SKATE3_ASSETS%" --start-paused %* +"%MARKER_DIR%\skate3-session-marker.exe" --assets "%SKATE3_ASSETS%" --start-paused %* if errorlevel 1 pause diff --git a/docs/session-markers.md b/docs/session-markers.md index a9a416f5..bd99db77 100644 --- a/docs/session-markers.md +++ b/docs/session-markers.md @@ -25,7 +25,7 @@ SHA-256 provenance: ```text Owned default.xex: 1db39496585c521d17a2137804f42cf73ebed2b32cac166ec42dbf772f4dcf7f TU3 disassembly image: f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4 -Private manual executable: 38be66b784b64d87d2134120b9bf582c74b295d4a01b1d1e9246cf62b4f0775f +Corrected private manual executable: cf5e5e0cc4ed88547f03a8a1ebe628133d4b58ecc451ecad4d59276d1ba22cbf ``` | Native owner | Behavior represented | @@ -96,6 +96,15 @@ Release compilation passed with the existing static-CRT Windows target flags: cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --no-default-features ``` +The first staged executable was subsequently found to be an older build from +the shared target directory: it contained neither `--start-paused` nor the +marker shader. The compile result did not verify the staged artifact. That +executable has been superseded by `.local/session-marker/skate3-session-marker.exe`. +`Build-SessionMarker.ps1` now links directly into the task's private build +directory with a distinct artifact name, checks the CLI/marker strings without +executing it, and verifies the staged SHA-256. Use that script for future builds; +`-DependencyTargetDirectory` can select an existing dependency cache. + Three standalone hold-state tests passed (cancellation/latching, unset/nearby/ blocked returns and tail, distance ramp). The WGSL passed offline Naga27 parsing and validation. Original HUD manifest counts, triangle structure and every RGBA From 4d3970f47301e20b796803a6dc6786ea2cc2b600 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:11:14 +0100 Subject: [PATCH 4/5] Correct native marker HUD font passes, button geometry and distance source --- crates/skate-game/src/session_marker/mod.rs | 11 ++- crates/skate-game/src/session_marker/state.rs | 10 +++ docs/session-markers.md | 30 +++++-- tools/extract_session_marker.py | 80 ++++++++++++------- 4 files changed, 91 insertions(+), 40 deletions(-) diff --git a/crates/skate-game/src/session_marker/mod.rs b/crates/skate-game/src/session_marker/mod.rs index c76cc520..b18fe78f 100644 --- a/crates/skate-game/src/session_marker/mod.rs +++ b/crates/skate-game/src/session_marker/mod.rs @@ -75,6 +75,7 @@ fn suspend( map: Res, menu: Res, replay: Res, + time: Res>, ) { if marker.generation != map.generation { *marker = SessionMarker { @@ -89,6 +90,9 @@ fn suspend( marker.progress = 0.; marker.blocked_until_release = true; marker.ui_time = 0.; + } else if !marker.blocked_until_release { + // PlayerUI runs on the UI clock, independently of physics substeps. + marker.ui_time += time.delta_secs_f64(); } } @@ -100,7 +104,6 @@ fn update( mut skater: ResMut, validation: Res, replay: Res, - time: Res>, mut audio: MessageWriter, ) { if replay.active { @@ -175,10 +178,12 @@ fn update( && !(physics.board_wiping_out && p.skeleton.teleport_pending_604 != 0) && skater.player_input.pending_teleport().is_none(); let distance = session.marker.map_or(0., |m| { - Vec3::from_slice(&transform[3][..3]).distance(Vec3::from_slice(&m.transform[3][..3])) + // 82DB6EC0 -> 82BE1AE8 publishes animation-to-world at output+368; + // UpdateSessionMarker reads its translation at output+416. + Vec3::from_slice(&skater.animated_skeleton.roots.animation_to_world[3][..3]) + .distance(Vec3::from_slice(&m.transform[3][..3])) }); let usable = session.can_return; - session.ui_time += time.delta_secs_f64(); while session.ui_time >= 1. / 60. { session.ui_time -= 1. / 60.; let step = session.hold.update(held, usable, distance, ready); diff --git a/crates/skate-game/src/session_marker/state.rs b/crates/skate-game/src/session_marker/state.rs index 51afed67..e7c1cbff 100644 --- a/crates/skate-game/src/session_marker/state.rs +++ b/crates/skate-game/src/session_marker/state.rs @@ -91,8 +91,18 @@ mod tests { } #[test] fn distance_ramp_matches_native_endpoints() { + assert_eq!(duration(1.), 0.2); assert_eq!(duration(100.), 0.2); assert!((duration(550.) - 0.6).abs() < 0.000001); assert_eq!(duration(1000.), 1.); + assert_eq!(duration(2000.), 1.); + } + #[test] + fn distance_changes_the_actual_relocation_tick() { + for (distance, expected_tick) in [(100., 13), (550., 36), (1000., 61)] { + let mut hold = Hold::default(); + let tick = (1..=120).find(|_| hold.update(true, true, distance, true).relocate); + assert_eq!(tick, Some(expected_tick), "distance {distance}"); + } } } diff --git a/docs/session-markers.md b/docs/session-markers.md index bd99db77..ab7bf45a 100644 --- a/docs/session-markers.md +++ b/docs/session-markers.md @@ -25,12 +25,15 @@ SHA-256 provenance: ```text Owned default.xex: 1db39496585c521d17a2137804f42cf73ebed2b32cac166ec42dbf772f4dcf7f TU3 disassembly image: f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4 -Corrected private manual executable: cf5e5e0cc4ed88547f03a8a1ebe628133d4b58ecc451ecad4d59276d1ba22cbf +HUD-corrected private manual executable: 169d6258cb2a62b4e6caa0ad54788ab66f2d9587231a9d056b9dc984169040d4 ``` | Native owner | Behavior represented | | --- | --- | | `82898FC8` PlayerUI::UpdateSessionMarker | Actions42/43; set/return ordering; retained timer and release latch | +| `82DB6EC0`, `82BE1AE8` | Skeleton output+416 is animation-to-world translation, used for distance to the saved marker | +| `825DF618` FrontEndManager::RenderButton | Full texture-sized quad, minimum corner (-24,-24), rather than the authored 32px placeholder | +| `825D6B68`, `82CA1FD8` | Futura Shadow draws black first; original futuraheavy draws text color at local X+1 | | `8289B6D8`, `8289B140` | Saved transform, foot-forward and on-board byte; marker initially unset | | `8289B928`, `8289BAE0` | Ground/biped eligibility; wheel count, deck Up.Y threshold, excluded states | | `82BFBC18` | Downward10m line from Y+.1; VLT slope/drop/clearance; surface switch | @@ -45,6 +48,10 @@ Corrected private manual executable: cf5e5e0cc4ed88547f03a8a1ebe628133d4b58ecc45 Return duration is.2s through100m,1s from1000m, and a native fused linear ramp between them (`3A690453`, `3DE38E39`). The timer adds `3C888889` per UI tick. +The host budgets those ticks from real UI time, once per frame, and consumes +them alongside controller input; physics substeps no longer supply the clock. +Distance uses the animation-root translation, not the board+.2 capture position. +At 100m, 550m and 1000m the strict comparison fires on ticks 13, 36 and 61. Relocation uses a strict greater-than comparison. Within.5m there is no effect or relocation; the native timer still accumulates. The trigger tick and next two UI ticks publish full effect. The shader's recovered noise contribution uses @@ -71,12 +78,17 @@ existing APT/GEO/RX2/font readers perform extraction; this adapter compiles only the marker subtree. Re-running updates its source cache incrementally. Sources: `hud2/hudphonelist`, imported `controls/button_item2`, the Xbox360 -`button_DPad_Up_hud.Texture` and `button_DPad_Down_hud.Texture`, original Futura -Shadow atlas/metrics and English language labels. The compiler emits ten -triangle meshes and five private RGBA textures. The root `hudintro` endpoint14, -quick-menu `maximized` endpoint49 and two-item label endpoint8 supply the actual +`button_DPad_Up_hud.Texture`, `button_DPad_Down_hud.Texture` and `button_B_hud.Texture`, +original Futura Shadow and futuraheavy atlases/metrics and English language labels. +The compiler emits fifteen triangle meshes and seven private RGBA textures. +`futuraheavy` declares its bitmap font family as `Futura Std Medium`. +The root `hudintro` endpoint14, +quick-menu `maximized` endpoint49 and three-item label endpoint18 supply the actual placements. No replacement typeface or redrawn panel is used. Source texture and extraction-manifest hashes remain in the private compiled manifest. +Textures copy the extracted RGBA payload directly. Object Dropper is shown as +unavailable; its 0.3 opacity is a host presentation choice, not recovered +ActionScript behavior. This change does not add Object Dropper functionality. Runtime accepts `SKATE3_SESSION_MARKER_OVERLAY`, falling back to `ASSET_ROOT/private/session-marker`. This is the narrow interface for a future @@ -105,10 +117,14 @@ directory with a distinct artifact name, checks the CLI/marker strings without executing it, and verifies the staged SHA-256. Use that script for future builds; `-DependencyTargetDirectory` can select an existing dependency cache. -Three standalone hold-state tests passed (cancellation/latching, unset/nearby/ -blocked returns and tail, distance ramp). The WGSL passed offline Naga27 parsing +Four standalone hold-state tests passed (cancellation/latching, unset/nearby/ +blocked returns and tail, distance ramp, actual distance-dependent trigger ticks). +The WGSL previously passed offline Naga27 parsing and validation. Original HUD manifest counts, triangle structure and every RGBA payload size passed data checks. These checks initialize no game or GPU device. +A CPU-only rendering of the compiled HUD was inspected for font layering, +icon size and the three-row panel. The corrected release was rebuilt and staged +through `Build-SessionMarker.ps1`; its artifact/hash checks passed. Manual validation remains necessary: place/replace, return from riding and biped states, switch/fakie stance, interrupted holds, bail recovery, camera cuts, diff --git a/tools/extract_session_marker.py b/tools/extract_session_marker.py index 38eb03c3..71edf053 100644 --- a/tools/extract_session_marker.py +++ b/tools/extract_session_marker.py @@ -25,9 +25,11 @@ def compile_hud(cache_root: Path, output: Path) -> None: if character["type_name"] in ("sprite", "animation"): count = character["movie"]["frame_count"] if owner["key"] == bundle: - label = {0: "hudintro", 17: "maximized", 16: "2"}.get(character["id"]) + label = {0: "hudintro", 17: "maximized", 16: "3"}.get(character["id"]) if label: return {"frame": playback_frame(character, label, play=True)} if count <= 1: return {"frame": 0} + if character["type_name"] == "text" and "/text2/" in path: + return {"text": language["ID_PHONELIST_OBJECTDROPPER"]} return {} flat = SceneFlattener(cache, state).flatten(bundle, 0) @@ -39,14 +41,13 @@ def compile_hud(cache_root: Path, output: Path) -> None: def texture(path, width=None, height=None): path = str(path) if path in texture_ids: return texture_ids[path] - from PIL import Image src = cache_root / path - with Image.open(src) as im: - im = im.convert("RGBA") - width, height = im.size - index = len(textures) - name = f"texture-{index}.rgba" - (output / name).write_bytes(im.tobytes()) + data = src.read_bytes() + if width is None or height is None or len(data) != width * height * 4: + raise ValueError(f"Invalid source RGBA texture: {path}") + index = len(textures) + name = f"texture-{index}.rgba" + (output / name).write_bytes(data) texture_ids[path] = index textures.append({"file": name, "width": width, "height": height, "source": path, "sha256": hashlib.sha256(src.read_bytes()).hexdigest()}) @@ -57,39 +58,41 @@ def compile_hud(cache_root: Path, output: Path) -> None: meshes = [] for primitive in flat["primitives"]: tex = primitive.get("texture") - index = texture(tex["preview"]) if tex else None + index = texture(tex["rgba"], tex["width"], tex["height"]) if tex else None role = "art" if "mButtonRender" in primitive["path"]: - role = "return" if "/mButton0/" in primitive["path"] else "place" - name = "button_DPad_Up_hud.Texture" if role == "return" else "button_DPad_Down_hud.Texture" + role = ("return" if "/mButton0/" in primitive["path"] else + "place" if "/mButton1/" in primitive["path"] else "dropper") + name = {"return": "button_DPad_Up_hud.Texture", + "place": "button_DPad_Down_hud.Texture", "dropper": "button_B_hud.Texture"}[role] item = next(t for t in buttons if t["name"] == name) - index = texture(buttons_dir / item["preview_file"]) - # SetButtonType replaces the authored 32px button render rectangle. - # Preserve its original geometry/placement and bind the retail icon. - inv = primitive["matrix"] - for triangle in primitive["triangles"]: - for vertex in triangle: - x, y = vertex["position"] - vertex["uv"] = [(x - inv[4] + 16.5) / 32, (y - inv[5] + 16.5) / 32] + index = texture(buttons_dir / item["rgba_file"], item["width"], item["height"]) + # RenderButton 825DF618 replaces the placeholder: full texture size, + # with (dimension - 48)/2 offset before +/- dimension/2. + corners = [(-24, -24, 0, 0), (item["width"]-24, -24, 1, 0), + (item["width"]-24, item["height"]-24, 1, 1), + (-24, item["height"]-24, 0, 1)] + vertices = [{"position": transform_point(primitive["matrix"], [x, y]), "uv": [u, v]} + for x, y, u, v in corners] + primitive["triangles"] = [[vertices[i] for i in indices] for indices in ((0,1,2),(0,2,3))] primitive["color"] = [1, 1, 1, primitive["color"][3]] + if role == "dropper": primitive["color"][3] *= 0.3 meshes.append({"texture": index, "role": role, "color": primitive["color"], "vertices": [dict(position=v["position"], uv=v.get("uv", [0, 0])) for tri in primitive["triangles"] for v in tri], "order": primitive["draw_order"], "source": primitive["path"]}) - for text in flat["text"]: - value = language.get(text["value"], text["value"]) - if value.startswith("ID_"): raise ValueError(f"Missing retail text {value}") - font = text["font_asset"] + def emit_text(text, value, font, tint, offset, role): definition = font["definition"] metrics = measure_bitmap_text(definition, value, text["font_height"]) - index = texture(font["preview"]) + size = definition["textures"][0] + index = texture(font["texture"], size["width"], size["height"]) atlas = textures[index] glyphs = {g["glyph_index"]: g for g in definition["glyphs"]} vertices = [] scale = metrics["scale"] for used in metrics["glyphs"]: g = glyphs[used["glyph_index"]] - x = text["bounds"][0] + 2 + used["left"] + x = text["bounds"][0] + 2 + used["left"] + offset y = text["bounds"][1] + 2 + text["font_height"] - g["y_offset"] * scale w, h = g["width"] * scale, g["height"] * scale a, b, c, d = g["atlas_bounds"] @@ -98,18 +101,35 @@ def compile_hud(cache_root: Path, output: Path) -> None: px,py,u,v = corners[corner] vertices.append({"position": transform_point(text["matrix"], [px,py]), "uv": [u/atlas["width"],v/atlas["height"]]}) + meshes.append({"texture": index, "role": role, "color": tint, + "vertices": vertices, "order": text["draw_order"] + offset * 0.1, + "source": text["path"], "font_pass": "shadow" if offset == 0 else "foreground"}) + + for text in flat["text"]: + value = language.get(text["value"], text["value"]) + if value.startswith("ID_"): raise ValueError(f"Missing retail text {value}") + role = ("return" if "/text0/" in text["path"] else + "place" if "/text1/" in text["path"] else "dropper") argb = int(text["color_argb"].lstrip("#"), 16) - meshes.append({"texture": index, "role": "return" if "/text0/" in text["path"] else "place", - "color": [((argb >> n) & 255)/255 for n in (16,8,0)] + [text["alpha"]*((argb>>24)&255)/255], - "vertices": vertices, "order": text["draw_order"], "source": text["path"]}) + color = [((argb >> n) & 255)/255 for n in (16,8,0)] + [text["alpha"]*((argb>>24)&255)/255] + if role == "dropper": color[3] *= 0.3 + # AllocateString 825D6B68 / SkateAptString::Render 82CA1FD8: + # Futura Shadow is black, then futuraheavy in the text color at x+1. + passes = [(text["font_asset"], [0, 0, 0, color[3]], 0), + (cache.font_asset("Futura Std Medium"), color, 1)] + for font, tint, offset in passes: + if font is None: raise ValueError("Missing original futuraheavy font") + emit_text(text, value, font, tint, offset, role) + manifest = {"version": 1, "canvas": [1280,720], "textures": textures, "meshes": sorted(meshes, key=lambda m:m["order"]), "source_manifest_sha256": hashlib.sha256((cache_root/"manifest.json").read_bytes()).hexdigest(), - "timelines": {"hudintro": [1,14], "hudoutro": [15,30], "maximized": [27,49]}, - "notes": "Authored display lists; two session-marker rows; dynamic button textures resolved by native SetButtonType names."} + "timelines": {"hudintro": [1,14], "hudoutro": [15,30], "maximized": [27,49], "3": [9,18]}, + "notes": "Authored three-row display list; native RenderButton dimensions and dual font passes. Object Dropper is unavailable; its 0.3 opacity is a host presentation choice."} (output/"hud.json").write_text(json.dumps(manifest, indent=2)+"\n") + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--game", type=Path, required=True) From c46e164b773ad6f78cb9f9632cdb69aa258f09da Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:19:49 +0100 Subject: [PATCH 5/5] Adjust marker HUD shadow coverage and panel transparency --- docs/session-markers.md | 13 ++++++++++++- tools/extract_session_marker.py | 30 ++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/session-markers.md b/docs/session-markers.md index ab7bf45a..cfafd662 100644 --- a/docs/session-markers.md +++ b/docs/session-markers.md @@ -86,7 +86,14 @@ The root `hudintro` endpoint14, quick-menu `maximized` endpoint49 and three-item label endpoint18 supply the actual placements. No replacement typeface or redrawn panel is used. Source texture and extraction-manifest hashes remain in the private compiled manifest. -Textures copy the extracted RGBA payload directly. Object Dropper is shown as +Textures copy the extracted RGBA payload directly except for shadow coverage. +Following the retail/current comparison screenshots, the host linear-light +compositor uses shadow alpha `1-(1-a)^2.2` and panel opacity 0.75. These are +**screenshot-calibrated presentation adjustments**, not recovered native +constants or proof of native gamma equivalence. The shadow's original atlas +footprint and RGB, foreground font, icon bytes and all geometry are preserved. +Both source and output texture hashes are recorded in the private manifest. +Object Dropper is shown as unavailable; its 0.3 opacity is a host presentation choice, not recovered ActionScript behavior. This change does not add Object Dropper functionality. @@ -125,6 +132,10 @@ payload size passed data checks. These checks initialize no game or GPU device. A CPU-only rendering of the compiled HUD was inspected for font layering, icon size and the three-row panel. The corrected release was rebuilt and staged through `Build-SessionMarker.ps1`; its artifact/hash checks passed. +The subsequent shadow/panel adjustment changes only the extracted HUD overlay; +the same executable reads it on launch. Offline checks verified all output +hashes, unchanged foreground/icon bytes, unchanged shadow footprint and +increased shadow coverage. In-game comparison remains manual. Manual validation remains necessary: place/replace, return from riding and biped states, switch/fakie stance, interrupted holds, bail recovery, camera cuts, diff --git a/tools/extract_session_marker.py b/tools/extract_session_marker.py index 71edf053..1ecbddaf 100644 --- a/tools/extract_session_marker.py +++ b/tools/extract_session_marker.py @@ -10,6 +10,12 @@ import json from pathlib import Path import sys +# Presentation calibration against the supplied retail HUD captures. These are +# host compositor adjustments, not recovered APT constants. Keep the original +# geometry, RGB artwork and foreground font untouched. +PANEL_OPACITY = 0.75 +SHADOW_COVERAGE_GAMMA = 2.2 + def compile_hud(cache_root: Path, output: Path) -> None: from skate3_ui_extract.scene_graph import AssetCache, SceneFlattener, transform_point @@ -38,19 +44,29 @@ def compile_hud(cache_root: Path, output: Path) -> None: textures = [] texture_ids = {} - def texture(path, width=None, height=None): + def texture(path, width=None, height=None, *, shadow=False): path = str(path) - if path in texture_ids: return texture_ids[path] + key = (path, shadow) + if key in texture_ids: return texture_ids[key] src = cache_root / path data = src.read_bytes() if width is None or height is None or len(data) != width * height * 4: raise ValueError(f"Invalid source RGBA texture: {path}") + if shadow: + # Preserve the packaged blur profile. Compensate its coverage for + # linear-light black blending: 1-(1-a)^gamma. No blur/redrawn text. + pixels = bytearray(data) + for i in range(3, len(pixels), 4): + pixels[i] = round(255 * (1 - (1 - pixels[i] / 255) ** SHADOW_COVERAGE_GAMMA)) + data = bytes(pixels) index = len(textures) name = f"texture-{index}.rgba" (output / name).write_bytes(data) - texture_ids[path] = index + texture_ids[key] = index textures.append({"file": name, "width": width, "height": height, - "source": path, "sha256": hashlib.sha256(src.read_bytes()).hexdigest()}) + "source": path, "sha256": hashlib.sha256(src.read_bytes()).hexdigest(), + "output_sha256": hashlib.sha256(data).hexdigest(), + "shadow_coverage_gamma": SHADOW_COVERAGE_GAMMA if shadow else None}) return index buttons_dir = Path("assets/data/fe/source/images/buttons/xbox360/buttons") @@ -60,6 +76,7 @@ def compile_hud(cache_root: Path, output: Path) -> None: tex = primitive.get("texture") index = texture(tex["rgba"], tex["width"], tex["height"]) if tex else None role = "art" + primitive["color"][3] *= PANEL_OPACITY if "mButtonRender" not in primitive["path"] else 1 if "mButtonRender" in primitive["path"]: role = ("return" if "/mButton0/" in primitive["path"] else "place" if "/mButton1/" in primitive["path"] else "dropper") @@ -85,7 +102,7 @@ def compile_hud(cache_root: Path, output: Path) -> None: definition = font["definition"] metrics = measure_bitmap_text(definition, value, text["font_height"]) size = definition["textures"][0] - index = texture(font["texture"], size["width"], size["height"]) + index = texture(font["texture"], size["width"], size["height"], shadow=offset == 0) atlas = textures[index] glyphs = {g["glyph_index"]: g for g in definition["glyphs"]} vertices = [] @@ -125,7 +142,8 @@ def compile_hud(cache_root: Path, output: Path) -> None: "meshes": sorted(meshes, key=lambda m:m["order"]), "source_manifest_sha256": hashlib.sha256((cache_root/"manifest.json").read_bytes()).hexdigest(), "timelines": {"hudintro": [1,14], "hudoutro": [15,30], "maximized": [27,49], "3": [9,18]}, - "notes": "Authored three-row display list; native RenderButton dimensions and dual font passes. Object Dropper is unavailable; its 0.3 opacity is a host presentation choice."} + "presentation": {"panel_opacity": PANEL_OPACITY, "shadow_coverage_gamma": SHADOW_COVERAGE_GAMMA}, + "notes": "Authored three-row display list; native RenderButton dimensions and dual font passes. Panel opacity and shadow coverage compensate the host compositor against reference screenshots; they are not recovered native constants. Object Dropper is unavailable; its 0.3 opacity is a host presentation choice."} (output/"hud.json").write_text(json.dumps(manifest, indent=2)+"\n")