diff --git a/Launch.ps1 b/Launch.ps1 index d9770b27..4bddef50 100644 --- a/Launch.ps1 +++ b/Launch.ps1 @@ -6,7 +6,7 @@ try { if (-not (Test-Path -LiteralPath $executable)) { throw 'Game is not built. Run BUILD.bat first.' } New-Item -ItemType Directory -Path (Join-Path $PSScriptRoot 'logs') -Force | Out-Null $log = Join-Path $PSScriptRoot ('logs/game-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') - Write-Host 'Starting imported skater project (Easy). Use your XInput controller; Esc opens graphics/pause menu.' + Write-Host 'Starting imported skater project. Use your XInput controller; Esc opens difficulty/graphics/pause settings.' Write-Host "Log: $log" $errorLog = [System.IO.Path]::ChangeExtension($log, 'stderr.log') $assetArgument = '"' + (Join-Path $PSScriptRoot 'assets') + '"' diff --git a/README.md b/README.md index 2dc2174f..ba4175d7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ # Imported skater project Double-click **PLAY.bat** to launch. Use an XInput-compatible controller; -**Esc** opens the pause/graphics menu. Startup selects the supplied **Easy** profile. +**Esc** opens the pause/settings menu. Select **Easy**, **Normal**, or **Hardcore** +there; the selection saves for the next launch. The initial default is Easy. +Difficulty uses the extracted stock `physics_mode` tables and native selectors. +See [difficulty implementation and remaining parity gaps](docs/DIFFICULTY.md). The menu supports window resolution (720p through 4K), internal render scale (25–100%), supported MSAA levels (Off/2×/4×/8×), an FPS cap (30–240 or diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs index fbcef4e1..280c31c1 100644 --- a/crates/skate-game/src/config.rs +++ b/crates/skate-game/src/config.rs @@ -6,6 +6,7 @@ pub(crate) struct Config { pub asset_root: PathBuf, pub verification_capture: Option, pub map: Option, + pub difficulty: crate::difficulty::Difficulty, } impl Config { @@ -14,7 +15,9 @@ impl Config { asset_root: PathBuf::from("assets"), verification_capture: None, map: None, + difficulty: crate::difficulty::Difficulty::default(), }; + let mut difficulty_override = None; let mut args = std::env::args_os().skip(1); while let Some(arg) = args.next() { match arg.to_str() { @@ -25,6 +28,10 @@ impl Config { let path = PathBuf::from(args.next().ok_or("--map requires a .skate file")?); config.map = Some(skate_data::skate_map::SkateMap::load(&path)?); } + 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())?); + } Some("--verify") => { config.verification_capture = Some( args.next() @@ -34,7 +41,7 @@ impl Config { } _ => { return Err(format!( - "Unknown argument {arg:?}. Usage: skate-game [--assets DIRECTORY] [--map MAP.skate] [--verify CAPTURE.png]" + "Unknown argument {arg:?}. Usage: skate-game [--assets DIRECTORY] [--map MAP.skate] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png]" )); } } @@ -43,6 +50,10 @@ impl Config { .asset_root .canonicalize() .map_err(|e| format!("Asset root {}: {e}", config.asset_root.display()))?; + config.difficulty = match difficulty_override { + Some(mode) => mode, + None => crate::difficulty::Difficulty::load(&config.asset_root)?, + }; if let Some(path) = &mut config.verification_capture { if path.extension().and_then(|x| x.to_str()) != Some("png") { return Err("--verify output must be a PNG file".into()); diff --git a/crates/skate-game/src/difficulty.rs b/crates/skate-game/src/difficulty.rs new file mode 100644 index 00000000..ee6c82d9 --- /dev/null +++ b/crates/skate-game/src/difficulty.rs @@ -0,0 +1,61 @@ +//! Host preference selects the native physics_mode collection, never a multiplier. +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub(crate) const NATIVE_MODES: [&str; 5] = ["easy", "normal", "hardcore", "motorized", "test"]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[repr(u32)] +pub(crate) enum Difficulty { + #[default] + Easy = 0, + Normal = 1, + Hardcore = 2, +} +impl Difficulty { + pub const ALL: [Self; 3] = [Self::Easy, Self::Normal, Self::Hardcore]; + pub fn key(self) -> &'static str { NATIVE_MODES[self as usize] } + pub fn label(self) -> &'static str { + match self { Self::Easy => "Easy", Self::Normal => "Normal", Self::Hardcore => "Hardcore" } + } + pub fn parse(value: &str) -> Result { + Self::ALL.into_iter().find(|d| d.key().eq_ignore_ascii_case(value)) + .ok_or_else(|| format!("Unknown difficulty {value:?}; expected easy, normal or hardcore")) + } + pub fn path(root: &Path) -> PathBuf { + root.parent().unwrap_or(root).join("settings/gameplay.json") + } + pub fn load(root: &Path) -> Result { + match std::fs::read(Self::path(root)) { + Ok(bytes) => serde_json::from_slice::(&bytes).map(|s| s.difficulty) + .map_err(|e| format!("Invalid saved gameplay settings: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(format!("Cannot read gameplay settings: {e}")), + } + } + pub fn save(self, root: &Path) -> Result<(), String> { + let path = Self::path(root); + std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?; + std::fs::write(path, serde_json::to_vec_pretty(&Saved { difficulty: self }).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) + } +} +#[derive(Serialize, Deserialize)] +struct Saved { difficulty: Difficulty } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn retail_indices_and_saved_names_agree() { + for (index, d) in Difficulty::ALL.into_iter().enumerate() { + assert_eq!(d as usize, index); + assert_eq!(Difficulty::parse(d.label()).unwrap(), d); + let bytes = serde_json::to_vec(&Saved { difficulty: d }).unwrap(); + assert_eq!(serde_json::from_slice::(&bytes).unwrap().difficulty, d); + } + assert!(Difficulty::parse("motorized").is_err()); + assert!(serde_json::from_str::(r#"{"difficulty":"made-up"}"#).is_err()); + } +} diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 9feb4120..d9ad400d 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -12,6 +12,7 @@ use bevy::{ window::{PresentMode, PrimaryWindow}, }; use serde::{Deserialize, Serialize}; +use crate::difficulty::Difficulty; use std::{ path::PathBuf, time::{Duration, Instant}, @@ -79,6 +80,7 @@ pub(crate) struct Menu { settings: GraphicsSettings, path: PathBuf, supported_msaa: Vec, + difficulty: Difficulty, status: String, } pub(crate) fn gameplay_active(menu: Option>) -> bool { @@ -190,14 +192,14 @@ fn setup( display: Display::None, width:percent(100), height:percent(100), align_items:AlignItems::Center, justify_content:JustifyContent::Center, position_type:PositionType::Absolute, ..default() }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.88)))).with_children(|root| { - root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(28)),flex_direction:FlexDirection::Column,row_gap:px(10),border_radius:BorderRadius::all(px(12)),..default() }, + root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() }, BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); - panel.spawn((Text::new("GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..7 { - panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(48),padding:UiRect::all(px(12)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, + panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); + for i in 0..8 { + panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { - row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:20.,..default()},TextColor(Color::WHITE))); + row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); }); } panel.spawn((StatusLabel,Text::new(""),TextFont {font_size:15.,..default()},TextColor(Color::srgb(0.65,0.75,0.8)))); @@ -211,6 +213,7 @@ fn setup( settings, path, supported_msaa, + difficulty: config.difficulty, status: String::new(), }); } @@ -227,6 +230,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 physics: ResMut, keys: Res>, mut menu: ResMut, mut time: ResMut>, @@ -239,10 +244,10 @@ fn interact( let mut action = None; if menu.open { if keys.just_pressed(KeyCode::ArrowUp) { - menu.selected = (menu.selected + 6) % 7; + menu.selected = (menu.selected + 7) % 8; } if keys.just_pressed(KeyCode::ArrowDown) { - menu.selected = (menu.selected + 1) % 7; + menu.selected = (menu.selected + 1) % 8; } if keys.just_pressed(KeyCode::ArrowLeft) { action = Some((menu.selected, -1)); @@ -274,8 +279,16 @@ fn interact( } 3 => menu.settings.fps = cycle(LIMITS, menu.settings.fps, direction), 4 => menu.settings.occlusion = !menu.settings.occlusion, - 5 => menu.open = false, - 6 => { + 5 => { + menu.difficulty = cycle(&Difficulty::ALL, menu.difficulty, direction); + physics.set_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}"), + }; + } + 6 => menu.open = false, + 7 => { exit.write(AppExit::Success); } _ => {} @@ -390,7 +403,8 @@ fn labels( } ), 4 => format!("Occlusion culling {}", if s.occlusion { "On" } else { "Off" }), - 5 => "Resume".into(), + 5 => format!("Difficulty {}", menu.difficulty.label()), + 6 => "Resume".into(), _ => "Quit game".into(), }; } @@ -428,7 +442,7 @@ mod tests { .insert_resource(images) .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), - path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), + difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), }) .add_systems(Update, apply); app.world_mut().spawn((Window::default(), PrimaryWindow)); diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index a0246c1d..8ff40a0a 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -4,6 +4,7 @@ mod app; mod assets; mod camera; mod config; +mod difficulty; mod graph_host; mod graph_runtime; mod input; @@ -48,14 +49,15 @@ fn main() -> bevy::app::AppExit { return bevy::app::AppExit::error(); } } - let physics = match physics::GamePhysics::load_with_map(&config.asset_root, config.map.as_ref()) { + eprintln!("SKATE_DIFFICULTY mode={} native_index={}", config.difficulty.key(), config.difficulty as u32); + let physics = match physics::GamePhysics::load_with_difficulty(&config.asset_root, config.map.as_ref(), config.difficulty) { Ok(physics) => physics, Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::error(); } }; - let skater = match physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, "easy") { + let skater = match physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, config.difficulty.key()) { Ok(skater) => skater, Err(error) => { eprintln!("{error}"); diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 8a395b63..577ac132 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -86,6 +86,14 @@ pub(crate) struct GamePhysics { } impl GamePhysics { + pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) { + // Actor publication carries this selector into the next physical packet. + // Keep the board, active trick, equipment preferences and controller history. + self.animation_profile.physics_mode = difficulty as u32; + } + + pub(crate) fn difficulty_index(&self) -> u32 { self.animation_profile.physics_mode } + pub(crate) fn world(&self) -> &BoardWorld { &self.world } @@ -99,9 +107,13 @@ impl GamePhysics { } pub fn load_with_map(asset_root: &std::path::Path, map: Option<&skate_data::skate_map::SkateMap>) -> Result { + Self::load_with_difficulty(asset_root, map, crate::difficulty::Difficulty::Easy) + } + + pub fn load_with_difficulty(asset_root: &std::path::Path, map: Option<&skate_data::skate_map::SkateMap>, difficulty: crate::difficulty::Difficulty) -> Result { let data = Collections::load(asset_root)?; let settings = PhysicsSettings::load(&data)?; - let animation_profile = animation_phase::AnimationProfile::load(&data, "easy")?; + let animation_profile = animation_phase::AnimationProfile::load(&data, difficulty.key())?; let mut spawn = RetailAffineTransform { translation: Vector3::new( 0.0, @@ -314,6 +326,10 @@ mod air_tests; #[path = "tests/wipeout_playback.rs"] mod wipeout_tests; +#[cfg(test)] +#[path = "tests/difficulty.rs"] +mod difficulty_tests; + fn present( physics: Res, skater: Res, diff --git a/crates/skate-game/src/physics/animation_input.rs b/crates/skate-game/src/physics/animation_input.rs index e618f6d4..7dc9cbb6 100644 --- a/crates/skate-game/src/physics/animation_input.rs +++ b/crates/skate-game/src/physics/animation_input.rs @@ -29,6 +29,7 @@ pub(crate) struct AnimationInput { bone_names: Vec, right_toe: usize, settings: FinalizationInput, + height_overrides: [bool; 5], } impl AnimationInput { @@ -39,6 +40,9 @@ impl AnimationInput { .position(|n| n.eq_ignore_ascii_case("RightToeBase")) .ok_or("Stock skeleton is missing RightToeBase")?; Ok(Self { + height_overrides: crate::difficulty::NATIVE_MODES.map(|key| + data.boolean("physics_mode", key, "JumpHeightOverrideEnabled")) + .into_iter().collect::, _>>()?.try_into().unwrap(), fields: ScalarAttributeInputs::reset(0, 0), extra: ExtendedAttributes::reset(0.0), contacts: ContactEventState { @@ -84,6 +88,12 @@ impl AnimationInput { }) } + pub fn select_physics_mode(&mut self, mode: u32) -> Result<(), String> { + self.settings.allow_height_override = *self.height_overrides.get(mode as usize) + .ok_or_else(|| format!("Invalid animation physics mode {mode}"))?; + Ok(()) + } + /// ProcessedPhysIn Reset precedes physical/packet flag publication. The /// caller then publishes those flags before dispatching the current pose. pub fn reset_processed(&mut self) { diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 6c547855..5ab14558 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -60,6 +60,10 @@ pub(super) fn advance( actions, input_available, )?; + skater.ground_settings = skater.ground_profiles.select( + skater.player_input.processed.state_variant_index_2528, + skater.player_input.processed.surface_mode_2540, + )?; if teleported { skater.player_state.reset_for_teleport(); skater.centre_of_mass_filter.reset(); diff --git a/crates/skate-game/src/physics/ground_runtime/mod.rs b/crates/skate-game/src/physics/ground_runtime/mod.rs index 1834c69e..01a2c904 100644 --- a/crates/skate-game/src/physics/ground_runtime/mod.rs +++ b/crates/skate-game/src/physics/ground_runtime/mod.rs @@ -22,6 +22,7 @@ mod settings; pub(crate) use launch::{GroundLaunchInfo, GroundLaunchPhysical}; pub(crate) use services::{GroundControllers, GroundPhysicalFrame}; pub(crate) use settings::GroundSettings; +pub(crate) use settings::GroundProfiles; use skate_core::{ math::Vector3, physics::{ diff --git a/crates/skate-game/src/physics/ground_runtime/settings.rs b/crates/skate-game/src/physics/ground_runtime/settings.rs index 539dff85..f17bd963 100644 --- a/crates/skate-game/src/physics/ground_runtime/settings.rs +++ b/crates/skate-game/src/physics/ground_runtime/settings.rs @@ -20,6 +20,24 @@ use skate_core::{ }, }; use skate_data::collections::Collections; +use std::sync::Arc; + +/// Immutable stock tables selected by the processed packet, including its +/// difficulty-dependent SurfacePhysics normalization. No per-tick parsing. +pub(crate) struct GroundProfiles(Vec>>); +impl GroundProfiles { + pub fn load(data: &Collections) -> Result { + crate::difficulty::NATIVE_MODES.into_iter().map(|mode| { + (1..=5).map(|surface| { + GroundSettings::load(data, mode, super::surface_key(surface)?).map(Arc::new) + }).collect::, String>>() + }).collect::, String>>().map(Self) + } + pub fn select(&self, mode: u32, surface: u32) -> Result, String> { + surface.checked_sub(1).and_then(|s| self.0.get(mode as usize)?.get(s as usize)) + .cloned().ok_or_else(|| format!("Invalid processed physics mode/surface {mode}/{surface}")) + } +} pub(crate) struct GroundSettings { steering: SteeringSettings, diff --git a/crates/skate-game/src/physics/ground_runtime/state.rs b/crates/skate-game/src/physics/ground_runtime/state.rs index e9ccfb30..575bef46 100644 --- a/crates/skate-game/src/physics/ground_runtime/state.rs +++ b/crates/skate-game/src/physics/ground_runtime/state.rs @@ -30,12 +30,12 @@ pub(crate) struct GroundState { pub heading_previous: f32, pub pumping_settings: GroundPumping, output_settings: GroundOutputSettings, - auto_push_enabled: bool, + auto_push_enabled: [bool; 5], pub entered: bool, pub(super) entry_settings: super::entry::EntrySettings, } impl GroundState { - pub fn load(data: &Collections, mode: &str, human_player: bool) -> Result { + pub fn load(data: &Collections, _mode: &str, human_player: bool) -> Result { let push = |field| data.float("physics_push", "default", field); Ok(Self { world_grab: super::world_grab::GroundWorldGrabState::default(), @@ -65,7 +65,9 @@ impl GroundState { mode_speed_threshold_0: push("Hash_501D5581043D7D3C")?, }, entry_settings: super::entry::EntrySettings::load(data)?, - auto_push_enabled: data.boolean("physics_mode", mode, "AutoPushEnabled")?, + auto_push_enabled: crate::difficulty::NATIVE_MODES.map(|mode| + data.boolean("physics_mode", mode, "AutoPushEnabled")) + .into_iter().collect::, _>>()?.try_into().unwrap(), entered: false, }) } @@ -86,7 +88,7 @@ impl GroundState { scalar_2720: animation.fields.balance, flags_2476: p.flags_2476, flags_2484: p.flags_2484, - selected_mode_flag_109: self.auto_push_enabled, + selected_mode_flag_109: self.auto_push_enabled[p.state_variant_index_2528 as usize], }, self.output_settings, ) diff --git a/crates/skate-game/src/physics/input_phase.rs b/crates/skate-game/src/physics/input_phase.rs index a7c6410e..ced3c7da 100644 --- a/crates/skate-game/src/physics/input_phase.rs +++ b/crates/skate-game/src/physics/input_phase.rs @@ -177,6 +177,7 @@ impl PlayerInputCallbacks for Callbacks<'_, '_> { physical: &mut PhysicalPlayerInput, processed: &mut ProcessedPhysicsInput, ) -> Result<(), String> { + self.animation_input.select_physics_mode(processed.state_variant_index_2528)?; self.skeleton_input.process_data( board, toolkit, diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 85e49669..39a72022 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -65,7 +65,8 @@ pub(crate) struct SkaterRuntime { pub skeleton_input: SkeletonInputRuntime, pub ground: GroundState, pub ground_runtime: GroundRuntime, - pub ground_settings: GroundSettings, + pub ground_settings: std::sync::Arc, + pub ground_profiles: super::ground_runtime::GroundProfiles, pub ground_lifecycle: super::ground_phase::GroundLifecycle, pub animation_input: AnimationInput, pub animation_feedback: AnimationFeedback, @@ -84,6 +85,9 @@ impl SkaterRuntime { mode: &str, ) -> Result { let data = Collections::load(asset_root)?; + let ground_profiles = super::ground_runtime::GroundProfiles::load(&data)?; + let mode_index = crate::difficulty::NATIVE_MODES.iter().position(|m| *m == mode) + .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"")?; @@ -231,7 +235,8 @@ impl SkaterRuntime { }, ground: GroundState::load(&data, mode, true)?, ground_runtime: GroundRuntime::load(&data)?, - ground_settings: GroundSettings::load(&data, mode, "smooth")?, + ground_settings: ground_profiles.select(mode_index, 1)?, + ground_profiles, ground_lifecycle: super::ground_phase::GroundLifecycle::new(), animation_input, animation_feedback: AnimationFeedback::load(&data)?, diff --git a/crates/skate-game/src/tests/air_playback.rs b/crates/skate-game/src/tests/air_playback.rs index d1c46cf0..c1ffddaa 100644 --- a/crates/skate-game/src/tests/air_playback.rs +++ b/crates/skate-game/src/tests/air_playback.rs @@ -21,7 +21,7 @@ fn roll_off(air_stick: Option) { let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = GamePhysics::load(root).unwrap(); + let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); // Keep this air fixture's open edge now that the production level has a // downhill ramp. The dedicated ramp regression uses the unmodified world. physics.world = BoardWorld::new(physics.world.triangles().iter().copied() @@ -144,7 +144,7 @@ fn stock_starting_ramp_reaches_floor_without_air() { let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = GamePhysics::load(root).unwrap(); + let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut input = crate::input::ControllerInput::default(); diff --git a/crates/skate-game/src/tests/difficulty.rs b/crates/skate-game/src/tests/difficulty.rs new file mode 100644 index 00000000..bf7e7dc5 --- /dev/null +++ b/crates/skate-game/src/tests/difficulty.rs @@ -0,0 +1,58 @@ +//! Integration with the extracted stock data and the real simulation pipeline. +use super::*; +use crate::difficulty::Difficulty; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn stock_difficulties_switch_without_resetting_the_player() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let data = Collections::load(root).unwrap(); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load_with_difficulty(root, None, Difficulty::Easy).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "easy").unwrap(); + let mut controls = PlayerControls::load(root).unwrap(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + + // Check the complete mode/surface product before exercising live switches. + for mode in Difficulty::ALL { + for surface in 1..=5 { + let settings = skater.ground_profiles.select(mode as u32, surface).unwrap(); + let key = ground_runtime::surface_key(surface).unwrap(); + assert_eq!(settings.wheel_material.dynamic_friction.to_bits(), + data.float("physics_surfaces", key, "WheelDynamicFriction").unwrap().to_bits()); + assert_eq!(settings.board().propulsion.mode_speed_changes.map(f32::to_bits), + ["MaxPushDVStart", "MaxPushDVEnd"].map(|f| data.float("physics_mode", mode.key(), f).unwrap().to_bits())); + assert_eq!(settings.wobble_amplitude.to_bits(), + data.float("physics_mode", mode.key(), "Hash_5B57F2CCCCEEF430").unwrap().to_bits()); + } + } + assert!(skater.ground_profiles.select(5, 1).is_err()); + assert!(skater.ground_profiles.select(0, 0).is_err()); + + for mode in [Difficulty::Easy, Difficulty::Normal, Difficulty::Hardcore, Difficulty::Easy] { + let before = physics.board.bodies().map(|b| (b.rates.position, b.rates.linear_velocity)); + let ticks = physics.ticks; + let generation = skater.pose_generation; + physics.set_difficulty(mode); + assert_eq!(physics.ticks, ticks); + assert_eq!(skater.pose_generation, generation); + assert_eq!(physics.board.bodies().map(|b| (b.rates.position, b.rates.linear_velocity)), before); + for _ in 0..60 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0, triggers: [0; 2], left: [0; 2], right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update(&mut actions, physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, skater.player_input.physical.scoring.capabilities_204); + controls.publish_gestures(physics.difficulty_index(), skater.player_input.physical.state.state_16); + frame::advance(&mut physics, &mut skater, &mut controls, &graphs, &mut actions, true, &mut camera).unwrap(); + assert_eq!(skater.player_input.processed.state_variant_index_2528, mode as u32); + let selected = skater.ground_profiles.select(mode as u32, skater.player_input.processed.surface_mode_2540).unwrap(); + assert!(std::sync::Arc::ptr_eq(&selected, &skater.ground_settings)); + } + assert_eq!(physics.ticks, ticks + 60); + } +} diff --git a/crates/skate-game/src/tests/physics_startup.rs b/crates/skate-game/src/tests/physics_startup.rs index ec4a7a84..83097702 100644 --- a/crates/skate-game/src/tests/physics_startup.rs +++ b/crates/skate-game/src/tests/physics_startup.rs @@ -7,7 +7,7 @@ fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() let root = std::path::Path::new(&root); let manifest = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); - let mut physics = GamePhysics::load(root).unwrap(); + let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); // This regression isolates grounded controls on the original flat fixture. // The production box is only six metres wide: sustained kickturning leaves // it at x=-3.32, correctly selecting KnownAir and ForcePhysics2. Separate @@ -363,7 +363,7 @@ fn stock_wheel_hardness_reaches_ground_force_and_torque() { let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); let mut samples = Vec::new(); for hardness in [0.0_f32, 0.7, 1.0] { - let mut physics = GamePhysics::load(root).unwrap(); + let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); diff --git a/crates/skate-game/src/tests/wipeout_playback.rs b/crates/skate-game/src/tests/wipeout_playback.rs index e6ad432b..4a35694c 100644 --- a/crates/skate-game/src/tests/wipeout_playback.rs +++ b/crates/skate-game/src/tests/wipeout_playback.rs @@ -9,7 +9,7 @@ fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = GamePhysics::load(root).unwrap(); + let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut input = crate::input::ControllerInput::default(); @@ -173,7 +173,7 @@ fn authored_checkpoint_reply_runs_teleport_state_and_restores_riding() { let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = GamePhysics::load(root).unwrap(); + let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut input = crate::input::ControllerInput::default(); diff --git a/crates/skate-game/src/verification.rs b/crates/skate-game/src/verification.rs index d02dfb65..c2971dcd 100644 --- a/crates/skate-game/src/verification.rs +++ b/crates/skate-game/src/verification.rs @@ -55,7 +55,7 @@ fn verify( } if state.captured && state.elapsed > 6. { let input_report = format!( - "Game integration capture (not a Skate 3 parity verdict)\nExecutable: {}\nPolls: {}\nConsumed batches: {}\nStatus: {:?}\nPacket numbers: {:?}\nActions 64..81 by device: {:?}\nDerived ticks: {}\nIntents: {:?}\nPhysics ticks: {}\nContacts: {}\nBody positions: {:?}\nPhysical pose publications: {}\nGraph animation ticks: {}\nTruck targets: {:?}\nGround speed: {}\nGameplay camera shot: {}\nCamera frame: {:?}\n", + "Game integration capture (not a Skate 3 parity verdict)\nExecutable: {}\nPolls: {}\nConsumed batches: {}\nStatus: {:?}\nPacket numbers: {:?}\nActions 64..81 by device: {:?}\nDerived ticks: {}\nIntents: {:?}\nDifficulty index: {}\nPhysics ticks: {}\nContacts: {}\nBody positions: {:?}\nPhysical pose publications: {}\nGraph animation ticks: {}\nTruck targets: {:?}\nGround speed: {}\nGameplay camera shot: {}\nCamera frame: {:?}\n", std::env::current_exe() .map_or_else(|_| "unavailable".into(), |p| p.display().to_string()), input.publications, @@ -65,6 +65,7 @@ fn verify( input.mapped_actions, controls.ticks, controls.intents, + physics.difficulty_index(), physics.ticks, physics.contact_count, physics.board.bodies().map(|body| body.rates.position), diff --git a/docs/DIFFICULTY.md b/docs/DIFFICULTY.md new file mode 100644 index 00000000..cacbec37 --- /dev/null +++ b/docs/DIFFICULTY.md @@ -0,0 +1,102 @@ +# Difficulty selection and parity boundary + +Escape offers Easy, Normal and Hardcore. Changes select the native physics-mode +index for the next simulation tick, without resetting bodies, the current +animation/trick, input history, equipment preferences or the camera. The choice +saves to `settings/gameplay.json`. Missing settings default to Easy. For a test +launch, `--difficulty easy|normal|hardcore` overrides the saved choice. + +## Data and code evidence + +The runtime already contains converted stock collection data in the private +`assets/private/stock/skater-collections.json`. This change uses its inherited +`physics_mode/easy`, `normal` and `hardcore` records through the existing typed +reader. It does not substitute hand-tuned force, jump or gravity multipliers. +The field values below are rounded for readability; the runtime reads the +original float bits. + +| Stock field | Easy | Normal | Hardcore | +| --- | ---: | ---: | ---: | +| MaxPushDVStart / MaxPushDVEnd | 1.5 / 1.5 | 0.6 / 0.5 | 0.5 / 0.25 | +| JumpMinHeight / JumpMaxHeight | 1.71 / 1.71 | 1.33 / 1.71 | 1.21 / 1.71 | +| JumpHeightOverrideEnabled | true | true | false | +| MaxAutoBodySpinSpeed | 8 | 8 | 0.5 | +| EasyBodySpins / PerfectBodyFlips | true / true | false / false | false / false | +| UnintentionalPumpScalar | 1 | 0.7 | 0 | +| AutoPushEnabled | true | false | false | +| GrindLockDist | 1.35 | 0.9 | 0.15 | + +These are inputs to the recovered calculations, not promises of a particular +measured jump height or speed. Source collection SHA-256 identities: + +- `physics_mode/easy.xml`: `4ee489b21c0e8f70616d0bf42f39a32604f3bec6ee5932671be7f9e5b9d6711c` +- `physics_mode/normal.xml`: `6054c99fd49265da916e647574566f8242adbf4f1d3e42bce2d3f8039ba25344` +- `physics_mode/hardcore.xml`: `44008a66b6f275a21e067ee46bf53be2927379904c97bfc5572ebc71f647aa68` + +Native reference: the user's Skate3Research TU3 memory image +`research/reverse-engineering/XexDump/default_82000000_011B0000.bin`, +SHA-256 `f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`. +Addresses here are Xbox guest addresses, image base `0x82000000`. +The research generated instruction translation is supporting evidence; no +gameplay code or constants were copied from the older clone implementation. + +- Player constructor `82DB18A0`, variant setup `82DB1B08..82DB1B78`: native order + Easy=0, Normal=1, Hardcore=2, Motorized=3, Test=4. Only the first three are + exposed to the player. +- Actor packet publication `825937EC` and PlayerInput `82DB4048`: carry the + selected index into processed input `+2528` and collection reference `+2548`. +- SurfacePhysics constructor `82D85D08`: five surface collections. The existing + native selector normalizes surface types according to mode field `+60`. + Ground force settings now follow that processed selection instead of always + reading the startup `smooth` collection. +- Existing gesture recognizer uses its native Hardcore branch for strength + response. It now receives the same selection as the physical/animation packet. + +## Connected runtime settings + +Previously, some systems already indexed all five native tables, while ground +forces, jump-attribute override and auto-push retained their startup values. +Now ground settings are preloaded for all five native modes and all five +surfaces. Selection is an immutable shared handle, with no file parsing per +tick. Jump-override selection happens after PlayerInput chooses the mode and +before Skeleton processes animation attributes. Auto-push reads that same +processed mode. Pumping, ground jumps, air spins/flips, wipeout checks and +trajectory grind-lock distance already consume indexed tables. + +## What is not yet full Skate 3 parity + +This is mode selection for the implemented native systems, not a claim that the +entire game now matches every retail mode. The current world adapter still has +`NoGrindEdges`, and physical grind-state updates remain incomplete. Changing +GrindLockDist does not make those missing controllers work. + +Six otherwise unused mode fields lie in the grind-state portion of the native +layout: `+84/+88`, `+92/+96`, `+100/+104`. Direct instruction inspection finds +interpolating consumers at `82D42720`, `82D40D60`, and `82D41CE8`, respectively, +using processed scalar `+2624`. Nearby native diagnostic strings identify grind +states. These consumers are observed in the binary; assigning new gameplay +semantics to the hashed field names would require more evidence. They have not +been replaced by invented assists or declared implemented merely because their +data can be read. + +No matched retail replay was run for this change, so complete behavioral parity +is not established. Remaining unsupported game states are still reported by the +existing runtime rather than simulated by substitute behavior. + +## Validation + +All 70 game tests passed with private fixtures enabled. The private integration +test switches all three modes during production ticks, +checks that mode selection preserves bodies and pose history, and compares all +15 player-mode/surface combinations with the exact stock push, friction and +wobble values. Legacy Normal-mode fixtures now initialize both physics and +skater as Normal instead of mixing an Easy packet with Normal cached settings. + +Run the complete game suite with private assets: + +```powershell +$env:SKATE3_ASSET_ROOT = Join-Path $PWD 'assets' +$env:SKATE3_MOTION_INVENTORY = Join-Path $PWD 'logs/motion-inventory.csv' +$env:SKATE_MAP_TEST_PATH = Join-Path $PWD 'maps/Skate_2_New_San_Vanelona.skate' +cargo test -p skate-game --locked -- --include-ignored +```