From a2ead834719b9eac17aaaca59a9f7b88f68ea3e5 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 15:51:15 +0100 Subject: [PATCH] Add playable customiser test build with transactional retail character swaps --- crates/skate-game/src/app.rs | 1 + crates/skate-game/src/camera.rs | 9 + crates/skate-game/src/customiser.rs | 601 ++++++++++++++++++ crates/skate-game/src/graphics_menu.rs | 32 +- crates/skate-game/src/input.rs | 2 +- crates/skate-game/src/input/platform.rs | 4 +- crates/skate-game/src/main.rs | 1 + crates/skate-game/src/physics.rs | 6 + docs/character-customisation.md | 57 +- tools/asset_pipeline/character_glb.py | 14 +- tools/asset_pipeline/customisation_worker.py | 260 ++++++++ tools/asset_pipeline/retail_character.py | 8 +- .../test_customisation_worker.py | 55 ++ tools/launch_customiser_test.bat | 21 + 14 files changed, 1035 insertions(+), 36 deletions(-) create mode 100644 crates/skate-game/src/customiser.rs create mode 100644 tools/asset_pipeline/customisation_worker.py create mode 100644 tools/asset_pipeline/test_customisation_worker.py create mode 100644 tools/launch_customiser_test.bat diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 7f970160..2af32e71 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::customiser::CustomiserPlugin, 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..b1707d55 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -52,6 +52,7 @@ fn spawn(mut commands: Commands, config: Res, retail: Res, windows: Query<&Window>, history: Res, time: Res>, replay: Res, + customiser: Option>, mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With>) { if let Ok(window) = windows.single() { runtime.set_aspect_ratio(window.width() / window.height()); @@ -65,6 +66,14 @@ fn present(mut runtime: ResMut, windows: Query<&Window>, if let Projection::Perspective(p) = &mut *projection { p.fov = previous.fov + (current.fov - previous.fov) * alpha; } + if customiser.as_ref().is_some_and(|c| c.open) { + let center = current.root.translation + Vec3::Y * 0.95; + let offset = current.root.rotation * Vec3::new(0.0, 0.25, 3.2); + let eye = center + offset; + let right = offset.normalize().cross(Vec3::Y).normalize(); + *transform = Transform::from_translation(eye).looking_at(center + right * 0.85, Vec3::Y); + if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); } + } camera.is_active = true; } } diff --git a/crates/skate-game/src/customiser.rs b/crates/skate-game/src/customiser.rs new file mode 100644 index 00000000..5fd0e95a --- /dev/null +++ b/crates/skate-game/src/customiser.rs @@ -0,0 +1,601 @@ +//! Draft/commit character editing over the existing retail GLB and animation rig. +use bevy::{asset::LoadState, gltf::Gltf, prelude::*}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{ + io::Write, + path::PathBuf, + process::{Command, Stdio}, + sync::{Mutex, mpsc}, +}; + +#[derive(Clone, Default, Deserialize)] +struct Entry { + label: String, + #[serde(default)] + children: Vec, + patch: Option, + scalar: Option, + note: Option, + minimum: Option, + maximum: Option, + initial: Option, + step: Option, +} +#[derive(Clone, Deserialize)] +struct Worker { + python: PathBuf, + working_directory: PathBuf, +} +#[derive(Resource, Default)] +pub(crate) struct Navigation { + pub pressed: u16, + previous: u16, +} +#[derive(Resource)] +pub(crate) struct Customiser { + pub open: bool, + just_opened: bool, + index: Entry, + path: Vec, + selected: usize, + draft: Value, + committed: Value, + scene: String, + committed_scene: String, + config: PathBuf, + settings: PathBuf, + worker: Option, + receiver: Option>>>, + loading: Option<(Handle, String, Value)>, + status: String, + redraw: bool, +} +impl Customiser { + pub(crate) fn begin(&mut self) { + self.open = true; + self.just_opened = true; + self.path.clear(); + self.selected = 0; + self.status = "Choose a category. Changes are previews until you Save.".into(); + self.redraw = true; + } + fn page(&self) -> &Entry { + let mut page = &self.index; + for &i in &self.path { + page = &page.children[i]; + } + page + } + fn busy(&self) -> bool { + self.receiver.is_some() || self.loading.is_some() + } + fn request(&mut self, profile: Value) { + if self.busy() { + self.status = "Please wait for the current preview.".into(); + return; + } + let Some(worker) = self.worker.clone() else { + self.status = "Private customisation worker is not configured.".into(); + return; + }; + let config = self.config.clone(); + let (tx, rx) = mpsc::channel(); + self.receiver = Some(Mutex::new(rx)); + self.status = "Preparing retail meshes and textures…".into(); + self.redraw = true; + std::thread::spawn(move || { + let run = || -> Result { + let mut command = Command::new(&worker.python); + command + .current_dir(&worker.working_directory) + .args([ + "-m", + "tools.asset_pipeline.customisation_worker", + "--config", + ]) + .arg(config) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let mut child = command + .spawn() + .map_err(|e| format!("Cannot start asset worker: {e}"))?; + child + .stdin + .take() + .ok_or("Worker stdin missing")? + .write_all(&serde_json::to_vec(&profile).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let output = child.wait_with_output().map_err(|e| e.to_string())?; + let response: Value = serde_json::from_slice(&output.stdout).map_err(|e| { + format!( + "Worker response: {e}; {}", + String::from_utf8_lossy(&output.stderr) + .chars() + .take(400) + .collect::() + ) + })?; + if let Some(e) = response.get("error").and_then(Value::as_str) { + return Err(e.into()); + } + if !output.status.success() { + return Err("Asset worker failed".into()); + } + Ok(response) + }; + let _ = tx.send(run()); + }); + } +} +#[derive(Component)] +struct Root; +#[derive(Component)] +struct Row(usize); +pub(crate) struct CustomiserPlugin; +impl Plugin for CustomiserPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(PreUpdate, navigation) + .add_systems(PostStartup, setup) + .add_systems( + Update, + (interact, complete, draw) + .chain() + .after(crate::graphics_menu::interact) + .before(crate::app::FrameSet::Animation), + ); + } +} +fn navigation(mut nav: ResMut) { + let current = (0..4) + .find_map(|i| crate::input::platform::poll(i).ok()) + .map_or(0, |p| p.state.buttons); + nav.pressed = current & !nav.previous; + nav.previous = current; +} +fn setup( + mut commands: Commands, + config: Res, + manifest: Res, +) { + let root = config.asset_root.join("private/customisation"); + let read = |name: &str| { + std::fs::read(root.join(name)) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + }; + let index = read("menu.json") + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or(Entry { + label: "Character customiser".into(), + note: Some("Prepare owned customisation assets for this installation.".into()), + ..default() + }); + let defaults = read("default.json") + .unwrap_or(json!({"selections":{},"morphs":{},"truck":0.7,"wheel":0.7,"posture":0})); + let settings = config + .asset_root + .parent() + .unwrap_or(&config.asset_root) + .join("settings/character.json"); + let saved = std::fs::read(&settings) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()); + let worker = read("worker.json").and_then(|v| serde_json::from_value(v).ok()); + let mut state = Customiser { + open: false, + just_opened: false, + index, + path: vec![], + selected: 0, + draft: defaults.clone(), + committed: defaults, + scene: manifest.0.character_scene.clone(), + committed_scene: manifest.0.character_scene.clone(), + config: root.join("worker.json"), + settings, + worker, + receiver: None, + loading: None, + status: String::new(), + redraw: true, + }; + if let Some(profile) = saved { + state.committed = profile.clone(); + state.request(profile); + } + commands.insert_resource(state); + commands.spawn(( + Root, + GlobalZIndex(20), + Node { + display: Display::None, + position_type: PositionType::Absolute, + width: percent(42), + height: percent(100), + padding: UiRect::all(px(20)), + flex_direction: FlexDirection::Column, + row_gap: px(6), + ..default() + }, + BackgroundColor(Color::srgba(0.025, 0.04, 0.06, 0.98)), + )); +} +fn merge(target: &mut Value, patch: &Value) { + if let (Some(a), Some(b)) = (target.as_object_mut(), patch.as_object()) { + for (k, v) in b { + merge(a.entry(k.clone()).or_insert(Value::Null), v); + } + } else { + *target = patch.clone(); + } +} +fn scalar(profile: &Value, key: &str, initial: f64) -> f64 { + let pointer = format!("/{}", key.replace('.', "/")); + profile + .pointer(&pointer) + .and_then(Value::as_f64) + .unwrap_or(initial) +} +fn interact( + keys: Res>, + nav: Res, + mut state: ResMut, + mut menu: ResMut, + server: Res, + buttons: Query<(&Interaction, &Row), Changed>, +) { + if !state.open { + return; + } + if state.just_opened { + state.just_opened = false; + return; + } + let mut action = None; + let count = state.page().children.len(); + if count > 0 { + if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { + state.selected = (state.selected + count - 1) % count; + state.redraw = true; + } + if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { + state.selected = (state.selected + 1) % count; + state.redraw = true; + } + if keys.just_pressed(KeyCode::Enter) + || keys.just_pressed(KeyCode::ArrowRight) + || nav.pressed & (0x1000 | 8) != 0 + { + action = Some((state.selected, 1)); + } + if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { + action = Some((state.selected, -1)); + } + } + let mut save = keys.just_pressed(KeyCode::F5) || nav.pressed & 0x4000 != 0; + let mut back = keys.just_pressed(KeyCode::Escape) + || keys.just_pressed(KeyCode::Backspace) + || nav.pressed & 0x2000 != 0; + let mut reset = false; + for (interaction, row) in &buttons { + if *interaction == Interaction::Pressed { + match row.0 { + usize::MAX => save = true, + x if x == usize::MAX - 1 => back = true, + x if x == usize::MAX - 2 => reset = true, + i => { + state.selected = i; + action = Some((i, 1)); + } + } + } + } + if save { + if state.busy() { + state.status = "Wait for the preview before saving.".into(); + } else { + let result = (|| -> Result<(), String> { + std::fs::create_dir_all(state.settings.parent().unwrap()) + .map_err(|e| e.to_string())?; + let temp = state.settings.with_extension("pending.json"); + std::fs::write( + &temp, + serde_json::to_vec_pretty(&state.draft).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + // Same directory replacement preserves the previous committed profile on write failure. + std::fs::rename(&temp, &state.settings).map_err(|e| e.to_string()) + })(); + match result { + Ok(()) => { + state.committed = state.draft.clone(); + state.committed_scene = state.scene.clone(); + state.status = "Saved. Back returns to skating with this character.".into(); + } + Err(e) => state.status = format!("Could not save: {e}"), + } + } + state.redraw = true; + } + if reset && !state.busy() { + let profile = std::fs::read(state.config.with_file_name("default.json")) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()); + if let Some(profile) = profile { + state.request(profile); + } + } + if back { + if !state.path.is_empty() { + state.selected = state.path.pop().unwrap(); + } else if state.busy() { + state.status = "Wait for the preview before leaving.".into(); + } else { + if state.draft != state.committed { + let scene = state.committed_scene.clone(); + let profile = state.committed.clone(); + state.loading = Some((server.load(scene.clone()), scene, profile)); + } + state.open = false; + menu.open = false; + } + state.redraw = true; + return; + } + if let Some((i, direction)) = action { + let Some(entry) = state.page().children.get(i).cloned() else { + return; + }; + if !entry.children.is_empty() { + state.path.push(i); + state.selected = 0; + } else if let Some(note) = entry.note { + state.status = note; + } else if !state.busy() { + let mut profile = state.draft.clone(); + if let Some(key) = entry.scalar { + let current = scalar(&profile, &key, entry.initial.unwrap_or(0.)); + let value = (current + direction as f64 * entry.step.unwrap_or(0.1)) + .clamp(entry.minimum.unwrap_or(0.), entry.maximum.unwrap_or(1.)); + if let Some((parent, child)) = key.split_once('.') { + profile[parent][child] = json!(value); + } else { + profile[&key] = json!(value); + } + // Equipment controls are consumed directly by existing native physics. + if key == "truck" || key == "wheel" { + state.draft = profile; + } else { + state.request(profile); + } + } else if let Some(patch) = entry.patch { + merge(&mut profile, &patch); + if patch.get("posture").is_some() { + state.draft = profile; + state.status="Posture is selected. The native motion tree applies it when skating resumes.".into(); + } else { + state.request(profile); + } + } + } else { + state.status = "Please wait for the current preview.".into(); + } + state.redraw = true; + } +} +fn complete( + mut state: ResMut, + server: Res, + mut commands: Commands, + root: Query>, + scenes: Query<(Entity, &ChildOf), With>, + mut animation: ResMut, + mut physics: ResMut, + mut skater: ResMut, +) { + let response = state + .receiver + .as_ref() + .and_then(|rx| rx.lock().ok()?.try_recv().ok()); + if let Some(response) = response { + state.receiver = None; + state.redraw = true; + match response { + Ok(value) => { + if let (Some(scene), Some(profile)) = ( + value.get("scene").and_then(Value::as_str), + value.get("profile"), + ) { + state.loading = + Some((server.load(scene.to_owned()), scene.into(), profile.clone())); + state.status = "Loading the assembled character…".into(); + } else { + state.status = "Asset worker returned an incomplete character.".into(); + } + } + Err(e) => state.status = format!("Preview unchanged: {e}"), + } + } + if let Some((handle, _, _)) = &state.loading { + if let Some(LoadState::Failed(error)) = server.get_load_state(handle.id()) { + state.status = format!("Preview unchanged: {error}"); + state.loading = None; + state.redraw = true; + } else if server.is_loaded_with_dependencies(handle.id()) { + if let Ok(root) = root.single() { + let (_, scene, profile) = state.loading.take().unwrap(); + for (entity, parent) in &scenes { + if parent.parent() == root { + commands.entity(entity).despawn(); + } + } + commands.entity(root).with_children(|p| { + p.spawn(SceneRoot( + server.load(GltfAssetLabel::Scene(0).from_asset(scene.clone())), + )); + }); + *animation = default(); + if profile == state.committed { + state.committed_scene = scene.clone(); + } + state.scene = scene; + state.draft = profile; + state.status = "Preview ready. Save to keep, or Back at the top to discard.".into(); + state.redraw = true; + } + } + } + physics.set_equipment_preferences( + scalar(&state.draft, "truck", 0.7).clamp(0., 1.) as f32, + scalar(&state.draft, "wheel", 0.7).clamp(0., 1.) as f32, + ); + skater + .animation + .motion + .animation + .posture + .set_profile(scalar(&state.draft, "posture", 0.).clamp(0., 3.) as u32); +} +fn draw( + mut commands: Commands, + mut state: ResMut, + mut root: Single<(Entity, &mut Node), With>, +) { + root.1.display = if state.open { + Display::Flex + } else { + Display::None + }; + if !state.open || !state.redraw { + return; + } + state.redraw = false; + commands.entity(root.0).despawn_children(); + let page = state.page(); + let start = state.selected / 10 * 10; + commands.entity(root.0).with_children(|p| { + p.spawn(( + Text::new(&page.label), + TextFont { + font_size: 25., + ..default() + }, + TextColor(Color::WHITE), + )); + p.spawn(( + Text::new("RETAIL CHARACTER • TEST BUILD"), + TextFont { + font_size: 13., + ..default() + }, + TextColor(Color::srgb(0.4, 0.85, 0.8)), + )); + for (i, entry) in page.children.iter().enumerate().skip(start).take(10) { + let value = entry + .scalar + .as_ref() + .map(|key| { + format!( + " {:.3}", + scalar(&state.draft, key, entry.initial.unwrap_or(0.)) + ) + }) + .unwrap_or_default(); + let suffix = if !entry.children.is_empty() { + " >" + } else { + "" + }; + p.spawn(( + Button, + Row(i), + Node { + width: percent(100), + min_height: px(32), + padding: UiRect::all(px(7)), + ..default() + }, + BackgroundColor(if state.selected == i { + Color::srgb(0.08, 0.32, 0.33) + } else { + Color::srgb(0.06, 0.10, 0.14) + }), + )) + .with_children(|r| { + r.spawn(( + Text::new(format!("{}{value}{suffix}", entry.label)), + TextFont { + font_size: 16., + ..default() + }, + TextColor(Color::WHITE), + )); + }); + } + if page.children.len() > 10 { + p.spawn(( + Text::new(format!( + "{}–{} / {} • Up/Down to browse", + start + 1, + (start + 10).min(page.children.len()), + page.children.len() + )), + TextFont { + font_size: 13., + ..default() + }, + )); + } + for (id, title) in [ + (usize::MAX, "Save [F5 / X]"), + (usize::MAX - 1, "Back [Esc / B]"), + (usize::MAX - 2, "Restore original character"), + ] { + p.spawn(( + Button, + Row(id), + Node { + padding: UiRect::all(px(7)), + ..default() + }, + BackgroundColor(Color::srgb(0.10, 0.17, 0.21)), + )) + .with_children(|r| { + r.spawn(( + Text::new(title), + TextFont { + font_size: 16., + ..default() + }, + TextColor(Color::WHITE), + )); + }); + } + p.spawn(( + Text::new(&state.status), + TextFont { + font_size: 14., + ..default() + }, + TextColor(Color::srgb(0.8, 0.85, 0.9)), + )); + p.spawn(( + Text::new( + "Enter / A: choose • Left/Right: adjust\nBack at the top discards unsaved changes.", + ), + TextFont { + font_size: 13., + ..default() + }, + TextColor(Color::srgb(0.55, 0.65, 0.7)), + )); + }); +} diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index a3d77f99..f6b90970 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -75,7 +75,7 @@ impl GraphicsSettings { } #[derive(Resource)] pub(crate) struct Menu { - open: bool, + pub(crate) open: bool, selected: usize, settings: GraphicsSettings, path: PathBuf, @@ -95,7 +95,7 @@ struct FramePacer(Instant); #[derive(Component)] struct MenuRoot; #[derive(Component)] -struct MenuRow(usize); +pub(crate) struct MenuRow(usize); #[derive(Component)] struct MenuLabel(usize); #[derive(Component)] @@ -198,7 +198,7 @@ fn setup( 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("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..10 { + for i in 0..11 { 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:18.,..default()},TextColor(Color::WHITE))); @@ -235,8 +235,10 @@ fn cycle(values: &[T], value: T, direction: i32) -> T { let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32; values[(index + direction).rem_euclid(values.len() as i32) as usize] } -fn interact( +pub(crate) fn interact( config: Res, + mut customiser: ResMut, + nav: Res, mut physics: ResMut, keys: Res>, mut menu: ResMut, @@ -244,21 +246,22 @@ fn interact( buttons: Query<(&Interaction, &MenuRow), Changed>, mut exit: MessageWriter, ) { - if keys.just_pressed(KeyCode::Escape) { + if customiser.open { return; } + if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 { menu.open = !menu.open; } let mut action = None; if menu.open { - if keys.just_pressed(KeyCode::ArrowUp) { - menu.selected = (menu.selected + 9) % 10; + if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { + menu.selected = (menu.selected + 10) % 11; } - if keys.just_pressed(KeyCode::ArrowDown) { - menu.selected = (menu.selected + 1) % 10; + if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { + menu.selected = (menu.selected + 1) % 11; } - if keys.just_pressed(KeyCode::ArrowLeft) { + if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { action = Some((menu.selected, -1)); } - if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) { + if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 { action = Some((menu.selected, 1)); } for (interaction, row) in &buttons { @@ -308,6 +311,7 @@ fn interact( 9 => { exit.write(AppExit::Success); } + 10 => customiser.begin(), _ => {} } if row < 5 { @@ -380,13 +384,14 @@ fn apply( } fn labels( menu: Res, + customiser: Res, window: Single<&Window, With>, mut root: Single<&mut Node, With>, mut labels: Query<(&MenuLabel, &mut Text), Without>, mut status: Single<&mut Text, With>, mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>, ) { - root.display = if menu.open { + root.display = if menu.open && !customiser.open { Display::Flex } else { Display::None @@ -424,7 +429,8 @@ fn labels( 6 => format!("Map {}", menu.maps[menu.selected_map].label), 7 => "Load map (restarts session)".into(), 8 => "Resume".into(), - _ => "Quit game".into(), + 9 => "Quit game".into(), + _ => "Character customiser".into(), }; } ***status = menu.status.clone(); diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 4ab9ad4d..8dfaf140 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -7,7 +7,7 @@ pub(crate) mod gesture_catalog; mod gesture_mapping_data; pub(crate) mod gesture_mapping; pub(crate) mod gesture_input; -mod platform; +pub(crate) mod platform; pub(crate) use controllers::{ControllerInput, ControllerStatus}; use skate_core::input::tick::TickInput; diff --git a/crates/skate-game/src/input/platform.rs b/crates/skate-game/src/input/platform.rs index 1b675331..b161f7c7 100644 --- a/crates/skate-game/src/input/platform.rs +++ b/crates/skate-game/src/input/platform.rs @@ -2,7 +2,7 @@ //! converter without Bevy/gilrs deadzones or normalized-axis reconstruction. use skate_core::input::xbox::XboxState; -pub(super) struct DevicePacket { +pub(crate) struct DevicePacket { pub number: u32, pub state: XboxState, pub subtype: u8, @@ -95,7 +95,7 @@ mod windows { } } -pub(super) fn poll(index: usize) -> Result { +pub(crate) fn poll(index: usize) -> Result { assert!(index < 4); #[cfg(windows)] return windows::poll(index as u32); diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 8d93deda..dce393e1 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -15,6 +15,7 @@ mod skater_animation; mod verification; mod performance; mod graphics_menu; +mod customiser; mod render_capacity; mod retail_render; mod presentation; diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 38f053cf..d653b5fb 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -169,6 +169,12 @@ mod exchange_tests { } impl GamePhysics { + pub(crate) fn set_equipment_preferences(&mut self, truck: f32, wheel: f32) { + if truck.is_finite() && wheel.is_finite() { + self.animation_profile.truck_tightness = truck.clamp(0.0, 1.0); + self.animation_profile.wheel_hardness = wheel.clamp(0.0, 1.0); + } + } 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. diff --git a/docs/character-customisation.md b/docs/character-customisation.md index 42893ecc..eef91f78 100644 --- a/docs/character-customisation.md +++ b/docs/character-customisation.md @@ -1,10 +1,43 @@ -# Character customisation: extraction boundary and native blockers +# Character customisation test build -Status: **not a playable customiser**. The runtime, Escape menu, character -loader, physics defaults, and renderer are unchanged by this work. No dummy -controls or replacement models were added. A faithful implementation is -blocked on the CAC material/compositor contract and the remaining native -assembly/profile application described below. +A dedicated local executable and `Play Character Customiser.bat` are now prepared. +The game was **not launched**: validation here is compilation, synthetic unit tests, +and offline retail asset assembly. User playtesting is still required. + +The Escape/Start menu opens a left-side character menu with the actual animated +skater framed on the right. Supported male model/material selections are assembled +on demand through the existing RX2/ABIN-to-GLB pipeline. The current character +stays visible during conversion or a failed request. Completed replacements reset +skin bindings before the animation system runs. Save commits the profile; +Back at the top discards unsaved changes. F5/controller X saves; Escape/controller +B goes back; arrows/D-pad select and adjust; Enter/controller A chooses. + +Working paths in this test include hair, hats, authored skin/facial-hair material +variants, clothing and accessories, boards, all 19 individual body/face morph +weights, native truck/wheel ranges, and posture selection. Equipment retains +its 0.7 defaults. Posture takes effect through the next eligible native motion +construction when skating resumes. The original character loads unchanged when +there is no saved custom profile. + +**This is not a complete retail CAC implementation.** Gender switching, facial +presets, tattoos, gestures, stance and style remain explicit informational pages. +The existing StandardMaterial shading is retained. Native stamp compositing, +colour-zone controls, unlock filtering, full material parity and all outfit +compatibility cases are unfinished. Authored asset names are shown where localized +retail display names have not been resolved. Do not describe this test as full +retail customiser parity. + +`tools/asset_pipeline/customisation_worker.py` provides the JSON worker and menu +index. A private `worker.json` holds local Python, source and asset-root paths; +retail data stays outside Git. The private test overlay links the existing stock, +skies and maps and owns its character cache and settings. It does not overwrite +an existing game executable or require another installation/conversion. + +Validation: 18 synthetic Python tests pass. Offline retail assemblies cover the +baseline, body/face changes, skin, beard, hair, hat, hoodie, pants, shoes, deck and +glasses. Packed 11/11/10 signed normal deltas accompany weighted body morphs; +normalization occurs after accumulating deltas. These checks are not a rendered +or interactive gameplay test. ## Implemented, private-data-only tooling @@ -151,16 +184,16 @@ CAC profile initialization must be traced before changing it. both genders against authored component skinning/bind matrices. Preserve hair-under-hat variants, arm/leg substitutions, socks, layered inner/outer tops, removed necklaces/wrist items and multiple material instances. The - existing GLB pipeline accepts only the fixed fallback component manifest; - extend it after the assembly contract is established. + test worker now supports male single-material mesh groups and applies the + explicit garment/hair/removal flags, but full native sequence parity remains. 3. **Complete profile/menu semantics.** Apply parent-row inheritance and native face presets, colour zones, available-item/unlock filters, tattoos, gestures, stance, style and posture. Keep physical board adjustments separate. Use actual saved defaults rather than constructor placeholders. -4. **Runtime/UI.** Implement the requested hierarchy, left controls and right - preview only for working native paths. Add transactional preview/apply/back, - coherent persistence, controller navigation and in-process character - replacement. No runtime or menu integration is present in this commit. +4. **Runtime/UI validation.** The test implementation now includes navigation, + preview/save/back, persistence and in-process replacement. It still requires + user gameplay testing, particularly replacement while paused, reopening saved + outfits and controller navigation. No game-based validation was run. The requested hierarchy is recorded verbatim in `HIERARCHY`. It is a scope contract, not a declaration that those pages work. The map-switch task's diff --git a/tools/asset_pipeline/character_glb.py b/tools/asset_pipeline/character_glb.py index f6c58937..5966dd70 100644 --- a/tools/asset_pipeline/character_glb.py +++ b/tools/asset_pipeline/character_glb.py @@ -32,8 +32,8 @@ class Glb: path.write_bytes(struct.pack('4 or not combined:raise ValueError('Invalid skin influences '+slot) for j,(bone,w) in enumerate(combined.items()):joints[i,j]=joint_index[bone];weights[i,j]=w weights/=weights.sum(axis=1,keepdims=True) - folder=private/'default_skater/textures/materials' + folder=materials or private/'default_skater/textures/materials' pbr={'baseColorFactor':[*component['tint'],1.0], 'baseColorTexture':glb.texture(folder/(slot+'_base_color.png')), 'metallicFactor':0.65 if slot=='SkateTruck' else 0.0, @@ -113,7 +115,7 @@ def convert(models,private,recipe): root=len(nodes);nodes.append({'name':'Skate3_RX2_Rig','children':root_children+[mesh_node]}) glb.doc.update(nodes=nodes,meshes=[{'primitives':primitives}],skins=[{'joints':list(range(len(indices))), 'inverseBindMatrices':inverse}],scenes=[{'nodes':[root]}],scene=0) - glb.save(private/'skater.glb') + glb.save(output or private/'skater.glb') if __name__=='__main__': import argparse diff --git a/tools/asset_pipeline/customisation_worker.py b/tools/asset_pipeline/customisation_worker.py new file mode 100644 index 00000000..bb72870e --- /dev/null +++ b/tools/asset_pipeline/customisation_worker.py @@ -0,0 +1,260 @@ +"""Private, on-demand retail CAC assembly. Never starts a game or an editor. + +The protocol is JSON on stdin/stdout. Paths belong to a local worker.json, +not the saved outfit. Failed assemblies never replace an existing GLB. +""" +from __future__ import annotations +import argparse, copy, hashlib, json, math, os, shutil, sys +from pathlib import Path + +from tools.asset_pipeline.customisation_catalog import write_private, selected_paths + +ROOT = Path(__file__).resolve().parents[2] + + +def flag(item, key): + return item['flags'].get('cas.' + key, '') + + +def label(name): + return name.replace('_', ' ').strip() + + +def default_profile(): + recipe = json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text()) + return {'selections': {c['slot']: {'asset_id': c['asset_id'], 'material_id': c['material_id']} + for c in recipe['components']}, 'morphs': {}, + 'truck': 0.7, 'wheel': 0.7, 'posture': 0} + + +def menu_index(catalog, native): + """Use authored names and IDs; no generated merchandise or synthetic presets.""" + mats = catalog['materials'] + slots = {c['slot']: c['models'] for c in catalog['components']} + def page(name, children=None, **extra): + return dict(label=name, children=children or [], **extra) + def unavailable(name, reason): + return page(name, note=reason) + def models(name, slot, predicate=lambda m: True): + children = [] + if slot in {'Hat', 'Hair', 'Glasses', 'Jewellery', 'WristItem', 'Sock', 'Accessory'}: + children.append(page('None', patch={'selections': {slot: None}})) + for m in slots[slot]: + if flag(m, 'Gender') not in {'', 'male', 'unisex'} or not predicate(m): + continue + lod = next(l for l in m['lods'] if l['index'] == 0) + if len(lod['material_instances']) != 1: + continue + variants = [] + for v in lod['material_instances'][0]: + if not any(t['channel'] == 'diffuse' for t in mats[v['id']]['textures']): + continue + variants.append(page(label(v['name']), patch={'selections': {slot: { + 'asset_id': m['id'], 'material_id': v['id']}}})) + if variants: + children.append(page(label(m['name']), variants)) + return page(name, children) + morphs = [] + for m in native['morphs']: + r = m['branch_a'] + morphs.append(page(label(m['key']), scalar='morphs.'+m['target'], + minimum=r['min'], maximum=r['max'], initial=r['default'], step=0.025)) + skin = sorted({flag(m, 'SkinTone') for m in mats.values()} - {''}) + beard = sorted({flag(m, 'FacialHairStyle') for m in mats.values()} - {''}) + body = page('Body mods', [ + unavailable('Gender', 'Male is supported in this test. Female material groups are still being decoded.'), + page('Skin', [page(x, patch={'skin': x}) for x in skin]), models('Hair', 'Hair'), + page('Body shape', morphs[:2]), + unavailable('Facial presets', 'Native preset arrays are indexed; preset application is not yet connected.'), + page('Face modification', morphs[2:]), + page('Facial hair', [page(x, patch={'beard': x}) for x in beard]), + unavailable('Upper body tattoo', 'Tattoo stamp compositing is not yet implemented.'), + unavailable('Lower body tattoo', 'Tattoo stamp compositing is not yet implemented.')]) + merch = [models('Hats', 'Hat')] + for title, kinds in [('Tshirts', {'tshirt','ls_tshirt','tanktop'}), ('Button shirts', {'buttonshirt'}), + ('Hoodies', {'hoody'}), ('Jackets', {'jacket'}), ('Sweaters', {'sweater'})]: + merch.append(models(title, 'OuterTorso', lambda m, k=kinds: flag(m,'TopType') in k)) + merch += [models('Pants','Pants'), models('Shoes','Feet'), models('Socks','Sock'), + page('Accessories', [models(s, s) for s in ['Glasses','Jewellery','WristItem','Accessory']]), + page('Skateboards', [models('Deck','SkateBoard'),models('Trucks','SkateTruck'),models('Wheels','SkateWheel')])] + style = page('Edit style', [unavailable('Gestures', 'Gesture selection is not yet connected.'), + unavailable('Stance', 'Changing the native stance publication is not yet connected.'), + unavailable('Style', 'Native style-to-animation selection is still being decoded.'), + page('Posture', [page(x, patch={'posture':i}) for i,x in enumerate(['Default','Stiff','Slouch','Buff'])])]) + return page('Character customiser', [body, page('Merchandise', merch), style, + page('Trucks adjustment',scalar='truck',minimum=0.,maximum=1.,initial=.7,step=.1), + page('Wheels adjustment',scalar='wheel',minimum=0.,maximum=1.,initial=.7,step=.1)]) + + +def resolve(catalog, profile): + profile = copy.deepcopy(profile) + models = {m['id']: (c['slot'],m) for c in catalog['components'] for m in c['models']} + materials = catalog['materials'] + selections = profile['selections'] + # Validate even entries which would subsequently be removed by a dependency. + for slot, selection in list(selections.items()): + if selection is None: + del selections[slot] + continue + if selection['asset_id'] not in models or models[selection['asset_id']][0] != slot: + raise ValueError('Unknown model in '+slot) + m = models[selection['asset_id']][1] + if flag(m,'Gender') not in {'','male','unisex'}: + raise ValueError('Female assembly is not supported by this test build') + groups = next(l for l in m['lods'] if l['index']==0)['material_instances'] + if len(groups)!=1 or selection['material_id'] not in {v['id'] for v in groups[0]}: + raise ValueError('Invalid material binding for '+slot) + def model(slot): + return models[selections[slot]['asset_id']][1] if slot in selections else None + def choose(slot, key, value): + if not value or value == 'none': + selections.pop(slot, None) + return + current=model(slot) + if current and flag(current,key)==value: + return + candidates=[m for s,m in models.values() if s==slot and flag(m,key)==value + and flag(m,'Gender') in {'','male','unisex'}] + if not candidates: + raise ValueError(f'No compatible {slot}: {key}={value}') + m=candidates[0] + variants=next(l for l in m['lods'] if l['index']==0)['material_instances'][0] + v=next((v for v in variants if flag(materials[v['id']],'IsDefault').lower()=='yes'), variants[0]) + selections[slot]={'asset_id':m['id'],'material_id':v['id']} + outer=model('OuterTorso') + if outer: + choose('Arm','ArmModelType',flag(outer,'ArmModelRequired')) + inner=flag(outer,'InnerCutTypeRequired') + choose('InnerTorso','InnerCutType',inner) + if flag(outer,'SupportsWristItem')=='no':selections.pop('WristItem',None) + if flag(outer,'IsNecklaceRemoved').lower()=='yes':selections.pop('Jewellery',None) + pants=model('Pants') + if pants:choose('Leg','LegModelType',flag(pants,'LegModelRequired')) + leg=model('Leg') + if leg: + sock=selections.get('Sock') + sock_style=flag(materials[sock['material_id']],'SockStyle') if sock else 'none' + candidates=[m for s,m in models.values() if s=='Leg' and flag(m,'Gender') in {'male','unisex',''} + and flag(m,'LegModelType')==flag(leg,'LegModelType') + and (flag(m,'RequiresSockStyle') or 'none')==sock_style] + if not candidates:raise ValueError('These socks do not fit the selected pants; choose another sock length') + if leg not in candidates: + m=candidates[0];variants=m['lods'][0]['material_instances'][0] + previous=selections['Leg']['material_id'] + v=next((v for v in variants if v['id']==previous),variants[0]) + selections['Leg']={'asset_id':m['id'],'material_id':v['id']} + hat=model('Hat') + if hat: + required=flag(hat,'RequiresHairModelType') + hair=model('Hair') + if required=='none':selections.pop('Hair',None) + elif required and hair and flag(hair,'HairModelType')!=required: + matches=[m for s,m in models.values() if s=='Hair' and flag(m,'Gender') in {'male','unisex',''} + and flag(m,'HairStyle')==flag(hair,'HairStyle') and flag(m,'HairModelType')==required] + if not matches:raise ValueError('This hat needs another hair style; choose None under Hair first') + m=matches[0];v=m['lods'][0]['material_instances'][0][0] + selections['Hair']={'asset_id':m['id'],'material_id':v['id']} + for slot in list(selections): + m=model(slot) + if m: + for removed in flag(m,'RequiresRemovalOfComponents').split(','): + selections.pop(removed.strip(),None) + # Keep exposed body material variants on the selected head's authored tone. + head=selections['Rostral'] + tone=profile.get('skin',flag(materials[head['material_id']],'SkinTone')) + beard=profile.get('beard',flag(materials[head['material_id']],'FacialHairStyle')) + for slot,sel in selections.items(): + old=materials[sel['material_id']] + if not flag(old,'SkinTone'):continue + variants=model(slot)['lods'][0]['material_instances'][0] + candidates=[v for v in variants if flag(materials[v['id']],'SkinTone')==tone + and (slot!='Rostral' or flag(materials[v['id']],'FacialHairStyle')==beard)] + if not candidates:raise ValueError(f'No authored {slot} material for skin {tone} / facial hair {beard}') + # Preserve unrelated eyebrow, hairline and skin-normal flags where possible. + def distance(v): + f=materials[v['id']]['flags'] + return sum(f.get(k)!=val for k,val in old['flags'].items() if k not in {'cas.SkinTone','cas.FacialHairStyle'}) + if not any(v['id']==sel['material_id'] for v in candidates): + sel['material_id']=min(candidates,key=distance)['id'] + allowed={'fat','thin'}|set(json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())['morph_assembly']['face_targets']) + for key,value in profile.get('morphs',{}).items(): + if key not in allowed:raise ValueError('Unknown morph target: '+key) + if not isinstance(value,(int,float)) or not math.isfinite(value) or not 0<=value<=.5: + raise ValueError('Morph value outside the native range: '+key) + return profile + + +def build(config, requested): + from tools.owned_game.big import BigArchive + from tools.extract_default_skater import import_rx2_parser, decode_texture, build_composite_images + from tools.asset_pipeline.retail_character import RX2, decode_dense_morphs + from tools.asset_pipeline.character_glb import convert + assets=Path(config['assets']); cache=assets/'private/customisation' + catalog=json.loads((cache/'catalog.json').read_text()) + profile=resolve(catalog,requested) + key=hashlib.sha256(json.dumps(profile,sort_keys=True).encode()).hexdigest()[:24] + work=cache/'builds'/key; output=work/'character.glb' + if output.is_file():return {'profile':profile,'scene':output.relative_to(assets).as_posix()} + work.mkdir(parents=True,exist_ok=True) + indexed={m['id']:m for c in catalog['components'] for m in c['models']} + selections=[dict(slot=s,asset_id=v['asset_id'],lod=0,material_ids=[v['material_id']]) + for s,v in profile['selections'].items()] + paths=selected_paths(catalog,selections) + archive=BigArchive(Path(config['game_root'])/'data/content/createacharacter.big') + entries={e.path.lower():e for e in archive.entries} + for path in paths: + dest=cache/'source'/path + if not dest.is_file():write_private(dest,archive.read(entries[path.lower()])) + parser=import_rx2_parser(ROOT/'tools/vendor/utt') + decoded=cache/'decoded'; decoded.mkdir(exist_ok=True) + recipe=json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text()) + original={c['slot']:c for c in recipe['components']} + recipe['components']=[];recipe['morph_assembly']['expected_targets']={} + for slot,sel in profile['selections'].items(): + m=indexed[sel['asset_id']];lod=next(l for l in m['lods'] if l['index']==0) + mat=catalog['materials'][sel['material_id']] + textures={t['channel']:t['id'] for t in mat['textures']} + if 'diffuse' not in textures:raise ValueError('No diffuse map for '+slot) + for t in mat['textures']: + dest=decoded/(t['id']+'.png') + if not dest.is_file():decode_texture(parser,cache/'source'/t['path'],dest) + target=work/'models'/slot/Path(lod['path']).name + target.parent.mkdir(parents=True,exist_ok=True) + if not target.exists():shutil.copyfile(cache/'source'/lod['path'],target) + parsed=RX2.parse_rx2(str(target));meshes=[m for m in parsed['meshes'] if m.get('positions') and m.get('indices')] + if len(meshes)!=1:raise ValueError('Unsupported mesh groups in '+slot) + morphs=decode_dense_morphs(target,parsed,len(meshes[0]['positions']),RX2) + recipe['morph_assembly']['expected_targets'][slot]=[m['name'] for m in morphs] + base=original.get(slot,{}) + # Preserve the existing renderer and its baseline material constants. + tint=base.get('tint',[1.,1.,1.]) if base.get('material_id')==sel['material_id'] else [1.,1.,1.] + recipe['components'].append(dict(slot=slot,textures=textures,tint=tint, + alpha_mode='MASK' if 'alpha' in textures else 'OPAQUE')) + body=recipe['preset']['body_mods'];values=profile.get('morphs',{}) + body.update(fatness=values.get('fat',0.),skinniness=values.get('thin',0.),targets=values) + material_root=work/'materials' + build_composite_images(recipe,decoded,material_root) + temporary=work/'character.pending.glb' + convert(work/'models',assets/'private',recipe,output=temporary,materials=material_root) + os.replace(temporary,output) + (work/'recipe.json').write_text(json.dumps(recipe,indent=2)) + return {'profile':profile,'scene':output.relative_to(assets).as_posix()} + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--config',type=Path,required=True) + args=p.parse_args();config=json.loads(args.config.read_text()) + request=json.load(sys.stdin) + try: + import contextlib + with contextlib.redirect_stdout(sys.stderr): + result=build(config,request) + except Exception as e: + import traceback + traceback.print_exc(file=sys.stderr) + result={'error':str(e)} + print(json.dumps(result)) + return 1 if 'error' in result else 0 + + +if __name__=='__main__':sys.exit(main()) diff --git a/tools/asset_pipeline/retail_character.py b/tools/asset_pipeline/retail_character.py index 64023a32..518df34e 100644 --- a/tools/asset_pipeline/retail_character.py +++ b/tools/asset_pipeline/retail_character.py @@ -333,7 +333,7 @@ def morph_weight(name: str, manifest: dict) -> float: if name == "thin" or name.startswith("thin_"): return float(body["skinniness"]) if name in manifest["morph_assembly"]["face_targets"]: - return float(body["face_fields"]) + return float(body.get("targets", {}).get(name, body["face_fields"])) raise RuntimeError(f"Retail morph target has no recipe mapping: {name}") def decode_dense_morphs( @@ -424,6 +424,7 @@ def decode_dense_morphs( ) deltas = [] + normal_deltas = [] nonzero_vertices = 0 nonzero_normal_deltas = 0 max_delta = 0.0 @@ -434,7 +435,9 @@ def decode_dense_morphs( x, y, z, _remap = struct.unpack_from( ">4h", data, vertex_offset ) - if be_u32(data, vertex_offset + 8) != 0: + packed_normal = be_u32(data, vertex_offset + 8) + normal_deltas.append(rx2_skeleton._dec_11_11_10(packed_normal)) + if packed_normal != 0: nonzero_normal_deltas += 1 if be_u32(data, vertex_offset + 12) != 0x3F800000: raise RuntimeError( @@ -456,6 +459,7 @@ def decode_dense_morphs( "hash": f"{target_hash:016x}", "descriptor_index": descriptor_index, "deltas": deltas, + "normal_deltas": normal_deltas, "nonzero_vertices": nonzero_vertices, "nonzero_normal_deltas": nonzero_normal_deltas, "max_source_delta": max_delta, diff --git a/tools/asset_pipeline/test_customisation_worker.py b/tools/asset_pipeline/test_customisation_worker.py new file mode 100644 index 00000000..0b03d2b7 --- /dev/null +++ b/tools/asset_pipeline/test_customisation_worker.py @@ -0,0 +1,55 @@ +"""Non-game protocol and dependency tests, with synthetic catalog data.""" +import copy +import unittest +from tools.asset_pipeline.customisation_worker import resolve +from tools.asset_pipeline.retail_character import morph_weight + + +def fixture(): + def mat(tone):return {'flags':{'cas.SkinTone':tone,'cas.FacialHairStyle':'none'}} + def model(id, gender='male', **flags): + return {'id':id,'flags':{'cas.Gender':gender,**{'cas.'+k:v for k,v in flags.items()}}, + 'lods':[{'index':0,'material_instances':[[{'id':'light'},{'id':'dark'}]]}]} + catalog={'materials':{'light':mat('light'),'dark':mat('dark')},'components':[ + {'slot':'Rostral','models':[model('head'),model('female','female')]}, + {'slot':'OuterTorso','models':[model('top',ArmModelRequired='wrist')]}, + {'slot':'Arm','models':[model('long_arm',ArmModelType='shoulder'),model('wrist',ArmModelType='wrist')]}]} + profile={'selections':{'Rostral':{'asset_id':'head','material_id':'light'}, + 'OuterTorso':{'asset_id':'top','material_id':'light'}, + 'Arm':{'asset_id':'long_arm','material_id':'light'}},'morphs':{}} + return catalog,profile + + +class WorkerTests(unittest.TestCase): + def test_dependency_replacement_does_not_mutate_request(self): + c,p=fixture();before=copy.deepcopy(p);out=resolve(c,p) + self.assertEqual(p,before) + self.assertEqual(out['selections']['Arm']['asset_id'],'wrist') + + def test_tone_propagates_to_exposed_skin(self): + c,p=fixture();p['skin']='dark';out=resolve(c,p) + self.assertTrue(all(v['material_id']=='dark' for v in out['selections'].values())) + + def test_rejects_cross_model_material_and_unsupported_gender(self): + c,p=fixture();p['selections']['Rostral']['material_id']='missing' + with self.assertRaisesRegex(ValueError,'Invalid material'):resolve(c,p) + p['selections']['Rostral']={'asset_id':'female','material_id':'light'} + with self.assertRaisesRegex(ValueError,'Female'):resolve(c,p) + + def test_rejects_out_of_range_and_unmapped_morphs(self): + c,p=fixture() + for values in [{'fat':.6},{'thin':float('nan')},{'invented':.25}]: + p['morphs']=values + with self.assertRaises(ValueError):resolve(c,p) + + def test_per_target_face_values_preserve_other_defaults(self): + recipe={'preset':{'body_mods':{'fatness':.4,'skinniness':.1,'face_fields':.25, + 'targets':{'local_nose_length':.5}}}, + 'morph_assembly':{'face_targets':['local_nose_height','local_nose_length']}} + self.assertEqual(morph_weight('local_nose_length',recipe),.5) + self.assertEqual(morph_weight('local_nose_height',recipe),.25) + self.assertEqual(morph_weight('fat_arms',recipe),.4) + self.assertEqual(morph_weight('thin_arms',recipe),.1) + + +if __name__=='__main__':unittest.main() diff --git a/tools/launch_customiser_test.bat b/tools/launch_customiser_test.bat new file mode 100644 index 00000000..dcfdf1bc --- /dev/null +++ b/tools/launch_customiser_test.bat @@ -0,0 +1,21 @@ +@echo off +setlocal +cd /d "%~dp0" +if not exist "%~dp0skate3rust-customiser.exe" ( + echo The dedicated customiser executable is missing. + pause + exit /b 1 +) +echo Skate 3 Character Customiser Test +echo Open Escape / Start, then choose Character customiser. +echo Save: F5 / controller X. Back: Escape / controller B. +echo First-time clothing changes can take a few seconds. +echo Diagnostic output is written to customiser-session.log. +"%~dp0skate3rust-customiser.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-session.log" 2>&1 +set "skate_customiser_exit=%errorlevel%" +if not "%skate_customiser_exit%"=="0" ( + echo The test exited with code %skate_customiser_exit%. + echo See %~dp0customiser-session.log + pause +) +exit /b %skate_customiser_exit%