diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 2d69fc86..40e3b4d7 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -29,6 +29,7 @@ fn bind( skins: Query<(Entity, &SkinnedMesh)>, nodes: Query<(&Name, &Transform)>, parents: Query<&ChildOf>, + visibility: Query<&Visibility>, roots: Query>, mut exit: MessageWriter, ) { @@ -37,12 +38,16 @@ fn bind( } let names = &skater.animation.evaluator.frames.bone_names; let result = (|| -> Result>, String> { + let mut bindings = Vec::new(); + let mut seen = std::collections::HashSet::new(); for (entity, skin) in &skins { if !parents.iter_ancestors(entity).any(|e| roots.contains(e)) { continue; } + if parents.iter_ancestors(entity).any(|e| visibility.get(e).is_ok_and(|v| *v == Visibility::Hidden)) { continue; } let mut binding = Vec::with_capacity(skin.joints.len()); for &joint in &skin.joints { + if !seen.insert(joint) { continue; } let (name, _) = nodes .get(joint) .map_err(|_| "Skater skin joint is missing its name or transform")?; @@ -80,13 +85,10 @@ fn bind( parent_bone, }); } - // All ten imported mesh primitives share these joint entities and - // their authored inverse-bind matrices. Bind the joints once and - // retain that mesh reference: the stock initialization pose is an - // animation pose, not a replacement skin bind pose. - return Ok(Some(binding)); + // Bind each visible modular rig, retaining its authored inverse binds. + bindings.extend(binding); } - Ok(None) + Ok(if bindings.is_empty() { None } else { Some(bindings) }) })(); match result { Ok(Some(binding)) => { diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index c725c647..e75ef255 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -106,6 +106,6 @@ pub(crate) fn build( verification::VerificationPlugin, crate::performance::PerformancePlugin, )); - app.add_plugins(crate::session_marker::SessionMarkerPlugin); + app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); app } diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index 651d0bd8..9664d5f7 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,15 @@ 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 let Some(customiser) = customiser.as_ref().filter(|c| c.open) { + let (height,distance,yaw)=customiser.preview_camera(); + let center = current.root.translation + Vec3::Y * height; + let offset = current.root.rotation * Quat::from_rotation_y(yaw) * Vec3::new(0.0, 0.25, distance); + let eye = center + offset; + let right = offset.normalize().cross(Vec3::Y).normalize(); + *transform = Transform::from_translation(eye).looking_at(center + right * (distance * 0.265625), 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..57ca9d24 --- /dev/null +++ b/crates/skate-game/src/customiser.rs @@ -0,0 +1,1420 @@ +//! Compact character menus over resident retail parts. +use crate::customiser_parts::{Library, Parts}; +use bevy::{input::mouse::MouseWheel, prelude::*}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::PathBuf; + +#[derive(Clone, Default, Deserialize)] +struct Entry { + label: String, + #[serde(default)] + children: Vec, + #[serde(default)] + options: Vec, + patch: Option, + scalar: Option, + note: Option, + gender: Option, + minimum: Option, + maximum: Option, + initial: Option, + step: Option, +} +#[derive(Resource, Default)] +pub(crate) struct Navigation { + pub pressed: u16, + preview_turn: f32, + previous: u16, + held_for: f32, + repeat_at: f32, +} +#[derive(Resource)] +pub(crate) struct Customiser { + pub open: bool, + pub enabled: bool, + just_opened: bool, + preview_yaw: f32, + index: Entry, + path: Vec, + selected: usize, + page_size: usize, + search: String, + pub draft: Value, + settings: PathBuf, + pub status: String, + pub redraw: bool, +} +impl Customiser { + pub(crate) fn begin(&mut self) { + self.open = true; + self.enabled = true; + self.just_opened = true; + self.preview_yaw = 0.; + self.path.clear(); + self.search.clear(); + self.selected = 0; + self.status.clear(); + self.redraw = true; + } + fn page(&self) -> &Entry { + let mut page = &self.index; + for &i in &self.path { + page = &page.children[i]; + } + page + } + fn visible(&self) -> Vec { + let gender = self.draft["gender"].as_str().unwrap_or("male"); + self.page() + .children + .iter() + .enumerate() + .filter(|(_, e)| { + e.gender + .as_deref() + .is_none_or(|g| g == gender || g == "unisex") + }) + .filter(|(_, e)| { + self.search.is_empty() + || e.label.to_lowercase().contains(&self.search.to_lowercase()) + }) + .map(|(i, _)| i) + .collect() + } + pub fn preview_camera(&self) -> (f32, f32, f32) { + let (height, distance, yaw) = self.preview_framing(); + (height, distance, yaw + self.preview_yaw) + } + fn preview_framing(&self) -> (f32, f32, f32) { + let mut page = &self.index; + let mut labels = vec![]; + for &i in &self.path { + page = &page.children[i]; + labels.push(page.label.as_str()); + } + if labels.contains(&"Upper body") || labels.contains(&"Lower body") { + let upper = labels.contains(&"Upper body"); + let side = self.draft["tattoos"][if upper { "Arm" } else { "Leg" }]["side"] + .as_u64() + .unwrap_or(0); + let yaw = match side { + 0 => -0.65, + 1 => 0.65, + 3 => std::f32::consts::PI, + _ => 0., + }; + return (if upper { 1.15 } else { 0.5 }, 2.2, yaw); + } + if labels.iter().any(|s| { + [ + "Face", + "Face presets", + "Hair", + "Hair colour", + "Facial hair", + "Skin tone", + ] + .contains(s) + }) { + return (1.48, 1.65, 0.); + } + if labels.contains(&"Board") { + return (0.25, 2.0, 0.6); + } + (0.95, 3.2, 0.) + } + pub fn preview(&self, parts: &Parts) -> Value { + let mut profile = self.draft.clone(); + if !self.open { + return profile; + } + let mut page = &self.index; + let mut zone = None; + let mut tattoos = false; + for &i in &self.path { + page = &page.children[i]; + if page.label == "Tattoos" { + tattoos = true; + } + if tattoos && page.label == "Upper body" { + zone = Some("OuterTorso"); + } + if tattoos && page.label == "Lower body" { + zone = Some("Pants"); + } + } + if let Some(slot) = zone { + let gender = profile["gender"].as_str().unwrap_or("male"); + let candidate = parts + .library + .models + .iter() + .filter(|(_, p)| { + p.slot == slot + && p.flag("Gender") == gender + && (if slot == "OuterTorso" { + p.flag("TopType").is_empty() + } else { + p.flag("IsTattooViewingChoice").eq_ignore_ascii_case("true") + }) + }) + .min_by_key(|(id, _)| *id); + if let Some((id, p)) = candidate { + profile["selections"][slot] = json!({"asset_id":id,"material_id":p.materials[0]}); + if let Ok(resolved) = parts.resolve(&profile) { + return resolved; + } + } + } + profile + } + fn preload_entries(&self) -> Vec<&Entry> { + if !self.open { + return vec![]; + } + let visible = self.visible(); + let start = self.selected / self.page_size * self.page_size; + visible + .iter() + .skip(start) + .take(self.page_size) + .flat_map(|&i| { + let e = &self.page().children[i]; + let current = option_index(e, &self.draft).unwrap_or(0); + e.options + .iter() + .enumerate() + .filter(move |(i, _)| *i < 4 || i.abs_diff(current) <= 2) + .map(|(_, v)| v) + .chain(std::iter::once(e)) + }) + .collect() + } + pub fn preload_outfits(&self, parts: &Parts) -> Vec { + self.preload_entries() + .into_iter() + .filter_map(|e| e.patch.as_ref()) + .filter_map(|patch| { + let mut profile = self.draft.clone(); + if let Some(gender) = patch["gender"].as_str() { + profile = parts.library.defaults.get(gender)?.clone(); + } else { + merge(&mut profile, patch); + if let Some(hair) = patch["selections"].get("Hair") { + profile["hair_choice"] = hair.clone(); + } + } + parts.resolve(&profile).ok() + }) + .collect() + } +} +#[derive(Component)] +struct Root; +#[derive(Component)] +struct Row(usize); +#[derive(Component)] +struct Adjust(usize, i32); +const BACK: usize = usize::MAX; +const NEXT: usize = usize::MAX - 1; +const PREV: usize = usize::MAX - 2; +const RESET: usize = usize::MAX - 3; +pub(crate) struct CustomiserPlugin; +impl Plugin for CustomiserPlugin { + fn build(&self, app: &mut App) { + crate::customiser_material::register(app); + app.init_resource::() + .add_systems(Startup, crate::customiser_parts::setup) + .add_systems(PreUpdate, navigation.before(crate::graphics_menu::MenuInput)) + .add_systems(PreUpdate, preferences.after(crate::map_transition::MapTransitionSet) + .before(crate::input::poll_controllers)) + .add_systems(PostStartup, setup) + .add_systems( + Update, + (interact, rotate_preview, crate::customiser_parts::update, draw) + .chain() + .after(crate::graphics_menu::interact) + .before(crate::app::FrameSet::Animation), + ); + } +} +fn navigation(mut nav: ResMut, time: Res>, keys: Res>) { + let pad = (0..4).find_map(|i| crate::input::platform::poll(i).ok()); + // Remap outside the dead zone so a resting stick cannot drift the preview. + let axis = pad.as_ref().map_or(0., |p| (p.state.right[0] as f32 / 32767.).clamp(-1., 1.)); + nav.preview_turn = axis.signum() * ((axis.abs() - 0.24) / 0.76).max(0.); + let mut current = pad.map_or(0, |p| { + p.state.buttons + | if p.state.left[1] > 16000 { + 1 + } else if p.state.left[1] < -16000 { + 2 + } else { + 0 + } + | if p.state.left[0] > 16000 { + 8 + } else if p.state.left[0] < -16000 { + 4 + } else { + 0 + } + }); + for (key, bit) in [ + (KeyCode::ArrowUp, 1), + (KeyCode::ArrowDown, 2), + (KeyCode::ArrowLeft, 4), + (KeyCode::ArrowRight, 8), + ] { + if keys.pressed(key) { + current |= bit; + } + } + nav.pressed = current & !nav.previous; + if current & 15 != 0 && current & 15 == nav.previous & 15 { + nav.held_for += time.delta_secs(); + if nav.held_for >= nav.repeat_at { + nav.pressed |= current & 15; + nav.repeat_at += 0.09; + } + } else { + nav.held_for = 0.; + nav.repeat_at = 0.35; + } + nav.previous = current; +} +fn rotate_preview(mut state: ResMut, nav: Res, time: Res>) { + if state.open && nav.preview_turn != 0. { + // Real time keeps inspection responsive while gameplay is paused. + state.preview_yaw = (state.preview_yaw + nav.preview_turn * 2.0 * time.delta_secs().min(0.1)) + .rem_euclid(std::f32::consts::TAU); + } +} + +fn page(label: impl Into, children: Vec) -> Entry { + Entry { + label: label.into(), + children, + ..default() + } +} +fn choice(label: impl Into, patch: Value) -> Entry { + Entry { + label: label.into(), + patch: Some(patch), + ..default() + } +} +fn tidy(s: &str) -> String { + s.split_whitespace() + .map(|w| { + let mut c = w.chars(); + c.next() + .map(|x| x.to_uppercase().collect::() + c.as_str()) + .unwrap_or_default() + }) + .collect::>() + .join(" ") +} +fn menu(lib: &Library, extras: Vec) -> Entry { + let model_page = |title: &str, slot: &str, types: &[&str]| { + let mut entries = vec![]; + if [ + "Hat", + "Hair", + "Sock", + "Glasses", + "Jewellery", + "WristItem", + "Accessory", + ] + .contains(&slot) + { + entries.push(choice("None", json!({"selections":{slot:Value::Null}}))); + } + if title == "T-shirts" { + for (id, p) in &lib.models { + if p.slot == "OuterTorso" && p.flag("TopType").is_empty() { + entries.push(Entry { gender:Some(p.flag("Gender").into()), + ..choice("No top",json!({"selections":{"OuterTorso":{"asset_id":id,"material_id":p.materials[0]}}})) }); + } + } + } + let mut models: Vec<_> = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == slot + && (slot != "Hair" || p.flag("HairModelType") == "full") + && (types.is_empty() || types.contains(&p.flag("TopType"))) + }) + .collect(); + models.sort_by(|a, b| a.1.name.cmp(&b.1.name).then(a.0.cmp(b.0))); + for (id, p) in models { + let options: Vec<_> = p + .materials + .iter() + .flat_map(|mid| { + let Some(m)=lib.materials.get(mid) else {return vec![];}; + let mut options=vec![choice(&m.name,json!({"selections":{slot:{"asset_id":id,"material_id":mid}},"colours":{slot:Value::Null}}))]; + if m.flag("IsColourizable").eq_ignore_ascii_case("true") { + options.extend(lib.colours.iter().map(|c| choice(&c.name, + json!({"selections":{slot:{"asset_id":id,"material_id":mid}},"colours":{slot:c.rgb}})))); + } + options + }) + .collect(); + if options.is_empty() { + continue; + } + let gender = p.flag("Gender"); + entries.push(Entry { + label: if slot == "Hair" { + tidy(p.flag("HairStyle").replace("moosed", "moussed").as_str()) + } else { + p.name.clone() + }, + options, + gender: if gender.is_empty() { + None + } else { + Some(gender.into()) + }, + ..default() + }); + } + if ["SkateBoard", "SkateTruck", "SkateWheel"].contains(&slot) { + let mut groups: std::collections::BTreeMap> = default(); + for e in entries { + for mut option in e.options { + let label = option.label.trim_end_matches(" (Worn)").to_owned(); + option.label = if option.label.ends_with(" (Worn)") { + "Worn" + } else { + "New" + } + .into(); + groups.entry(label).or_default().push(option); + } + } + entries = groups + .into_iter() + .map(|(label, options)| Entry { + label, + options, + ..default() + }) + .collect(); + } + page(title, entries) + }; + let morph = |title: &str, name: &str| Entry { + label: title.into(), + scalar: Some(format!("morphs.{name}")), + minimum: Some(0.), + maximum: Some(0.5), + initial: Some(if name == "fat" || name == "thin" { + 0. + } else { + 0.25 + }), + step: Some(0.025), + ..default() + }; + let mut body = page( + "Body", + vec![ + page( + "Gender", + vec![ + choice("Male", json!({"gender":"male"})), + choice("Female", json!({"gender":"female"})), + ], + ), + page( + "Skin tone", + ["light", "dark"] + .map(|s| choice(tidy(s), json!({"skin":s}))) + .to_vec(), + ), + model_page("Hair", "Hair", &[]), + page( + "Build", + vec![morph("Weight", "fat"), morph("Definition", "thin")], + ), + page( + "Face", + vec![ + page( + "Brows", + vec![ + morph("Depth", "local_brows_depth"), + morph("Height", "local_brows_height"), + morph("Angle", "local_brows_rotation"), + ], + ), + page( + "Eyes", + vec![ + morph("Height", "local_eye_height"), + morph("Angle", "local_eye_rotation"), + morph("Width", "local_eye_width"), + ], + ), + page( + "Nose", + vec![ + morph("Curve", "local_nose_curve"), + morph("Height", "local_nose_height"), + morph("Length", "local_nose_length"), + morph("Width", "local_nose_width"), + ], + ), + page( + "Mouth", + vec![ + morph("Corners", "local_mouth_corner"), + morph("Height", "local_mouth_height"), + morph("Fullness", "local_mouth_lipsize"), + morph("Width", "local_mouth_width"), + ], + ), + page( + "Jaw & chin", + vec![ + morph("Definition", "local_jaw_chiseled"), + morph("Depth", "local_jaw_depth"), + morph("Chin length", "local_chin_length"), + ], + ), + ], + ), + page("Facial hair", { + let mut names: Vec<_> = lib + .materials + .values() + .map(|m| m.flag("FacialHairStyle")) + .filter(|s| !s.is_empty()) + .collect(); + names.sort(); + names.dedup(); + names + .into_iter() + .map(|s| Entry { + gender: Some("male".into()), + ..choice( + match s { + "none" => "Clean shaven".into(), + "billygoat" => "Long goatee".into(), + "soulpatch" => "Soul patch".into(), + "magnum" => "Thick moustache".into(), + _ => tidy(s), + }, + json!({"beard":s}), + ) + }) + .collect() + }), + ], + ); + let mut tattoos: Vec<_> = lib.tattoos.iter().collect(); + tattoos.sort_by(|a, b| a.1.name.cmp(&b.1.name).then(a.0.cmp(b.0))); + body.children.push(page( + "Tattoos", + [("Upper body", "Arm"), ("Lower body", "Leg")] + .into_iter() + .map(|(name, slot)| { + let mut designs = + vec![choice("None", json!({"tattoos":{slot:{"id":Value::Null}}}))]; + designs.extend( + tattoos + .iter() + .map(|(id, t)| choice(&t.name, json!({"tattoos":{slot:{"id":id}}}))), + ); + page( + name, + vec![ + page("Design", designs), + page("Placement", { + let mut positions = vec![ + choice( + if slot == "Arm" { + "Left arm" + } else { + "Left leg" + }, + json!({"tattoos":{slot:{"side":0}}}), + ), + choice( + if slot == "Arm" { + "Right arm" + } else { + "Right leg" + }, + json!({"tattoos":{slot:{"side":1}}}), + ), + ]; + if slot == "Arm" { + positions.extend([ + choice("Chest", json!({"tattoos":{slot:{"side":2}}})), + choice("Back", json!({"tattoos":{slot:{"side":3}}})), + ]); + } + positions + }), + ], + ) + }) + .collect(), + )); + let clothes = page( + "Clothes", + vec![ + model_page("Hats", "Hat", &[]), + model_page( + "T-shirts", + "OuterTorso", + &["tshirt", "ls_tshirt", "tanktop"], + ), + model_page("Shirts", "OuterTorso", &["buttonshirt"]), + model_page("Hoodies", "OuterTorso", &["hoody"]), + model_page("Jackets", "OuterTorso", &["jacket"]), + model_page("Sweaters", "OuterTorso", &["sweater"]), + model_page("Pants & shorts", "Pants", &[]), + model_page("Shoes", "Feet", &[]), + model_page("Socks", "Sock", &[]), + page( + "Accessories", + vec![ + model_page("Glasses", "Glasses", &[]), + model_page("Necklaces", "Jewellery", &[]), + model_page("Wristwear", "WristItem", &[]), + model_page("Other", "Accessory", &[]), + ], + ), + ], + ); + let board = page( + "Board", + vec![ + model_page("Deck", "SkateBoard", &[]), + model_page("Trucks", "SkateTruck", &[]), + model_page("Wheels", "SkateWheel", &[]), + Entry { + label: "Truck tightness".into(), + scalar: Some("truck".into()), + minimum: Some(0.), + maximum: Some(1.), + initial: Some(0.7), + step: Some(0.1), + ..default() + }, + Entry { + label: "Wheel hardness".into(), + scalar: Some("wheel".into()), + minimum: Some(0.), + maximum: Some(1.), + initial: Some(0.7), + step: Some(0.1), + ..default() + }, + ], + ); + let mut root = page( + "Your skater", + vec![ + body, + clothes, + board, + page( + "Style", + vec![page( + "Posture", + ["Default", "Stiff", "Slouch", "Buff"] + .iter() + .enumerate() + .map(|(i, s)| choice(*s, json!({"posture":i}))) + .collect(), + )], + ), + ], + ); + for e in extras { + if e.label == "Style" { + root.children[3].children.extend(e.children); + } else if let Some(i) = root.children[0] + .children + .iter() + .position(|p| p.label == e.label) + { + root.children[0].children[i] = e; + } else { + root.children[0].children.push(e); + } + } + root +} +fn setup(mut commands: Commands, config: Res, parts: Res) { + let settings = config + .asset_root + .parent() + .unwrap_or(&config.asset_root) + .join("settings/character.json"); + let mut draft = parts + .library + .defaults + .get("male") + .cloned() + .unwrap_or(json!({"selections":{},"morphs":{}})); + let mut enabled = false; + let status = String::new(); + if let Ok(b) = std::fs::read(&settings) { + if let Ok(saved) = serde_json::from_slice::(&b) { + if let Ok(p) = parts.resolve(&saved) { + draft = p; + enabled = true; + } + } + } + let extras = std::fs::read( + config + .asset_root + .join("private/customisation/extra-menu.json"), + ) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + commands.insert_resource(Customiser { + open: false, + enabled, + just_opened: false, + preview_yaw: 0., + index: menu(&parts.library, extras), + path: vec![], + selected: 0, + page_size: 7, + search: String::new(), + draft, + settings, + status, + redraw: true, + }); + commands.spawn(( + Root, + GlobalZIndex(20), + Node { + display: Display::None, + position_type: PositionType::Absolute, + left: px(24), + top: percent(4), + width: percent(38), + min_width: px(370), + max_width: px(520), + height: percent(92), + padding: UiRect::all(px(22)), + flex_direction: FlexDirection::Column, + row_gap: px(10), + border_radius: BorderRadius::all(px(18)), + ..default() + }, + BackgroundColor(Color::srgba(0.025, 0.035, 0.048, 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 { + profile + .pointer(&format!("/{}", key.replace('.', "/"))) + .and_then(Value::as_f64) + .unwrap_or(initial) +} +fn option_index(entry: &Entry, profile: &Value) -> Option { + entry.options.iter().position(|o| { + o.patch.as_ref().is_some_and(|p| { + p.as_object().is_some_and(|fields| { + fields.iter().all(|(key, value)| { + if key == "selections" { + value.as_object().is_some_and(|selections| { + selections.iter().all(|(slot, v)| { + (if slot == "Hair" { + profile + .get("hair_choice") + .unwrap_or(&profile["selections"][slot]) + } else { + &profile["selections"][slot] + }) == v + }) + }) + } else { + matches_patch(&profile[key], value) + } + }) + }) + }) + }) +} + +fn interact( + keys: Res>, + mouse: Res>, + nav: Res, + mut wheel: MessageReader, + mut typing: MessageReader, + mut state: ResMut, + parts: Res, + mut pause: ResMut, + buttons: Query<(&Interaction, &Row)>, + adjustments: Query<(&Interaction, &Adjust)>, +) { + if !state.open { + wheel.clear(); + typing.clear(); + return; + } + if state.just_opened { + state.just_opened = false; + wheel.clear(); + typing.clear(); + return; + } + if state.page().children.iter().all(|e| e.children.is_empty()) { + for event in typing.read() { + if event.state == bevy::input::ButtonState::Pressed + && !keys.pressed(KeyCode::ControlLeft) + && !keys.pressed(KeyCode::ControlRight) + { + if let Some(text) = &event.text { + for c in text + .chars() + .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-') + { + if state.search.chars().count() < 32 { + state.search.push(c); + state.selected = 0; + state.redraw = true; + } + } + } + } + } + } else { + typing.clear(); + } + if !state.search.is_empty() { + if keys.just_pressed(KeyCode::Backspace) { + state.search.pop(); + state.selected = 0; + state.redraw = true; + return; + } + if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x2000 != 0 { + state.search.clear(); + state.selected = 0; + state.redraw = true; + return; + } + } + let visible = state.visible(); + let count = visible.len(); + let mut action = None; + let mut back = keys.just_pressed(KeyCode::Escape) + || keys.just_pressed(KeyCode::Backspace) + || nav.pressed & 0x2000 != 0; + let mut movement = 0; + if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { + movement -= 1; + } + if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { + movement += 1; + } + for scroll in wheel.read() { + if scroll.y > 0. { + movement -= 1; + } else if scroll.y < 0. { + movement += 1; + } + } + if keys.just_pressed(KeyCode::PageDown) { + movement += state.page_size as i32; + } + if keys.just_pressed(KeyCode::PageUp) { + movement -= state.page_size as i32; + } + if count > 0 { + if movement != 0 { + state.selected = (state.selected as i32 + movement).rem_euclid(count as i32) as usize; + state.redraw = true; + } + if keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0 { + action = Some((visible[state.selected], 0)); + } + if keys.just_pressed(KeyCode::ArrowRight) || nav.pressed & 8 != 0 { + action = Some((visible[state.selected], 1)); + } + if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { + action = Some((visible[state.selected], -1)); + } + } + if mouse.just_pressed(MouseButton::Left) { + for (interaction, row) in &buttons { + if *interaction == Interaction::Pressed { + match row.0 { + BACK => back = true, + NEXT => { + if count > 0 { + state.selected = (state.selected + state.page_size).min(count - 1); + state.redraw = true; + } + } + PREV => { + state.selected = state.selected.saturating_sub(state.page_size); + state.redraw = true; + } + RESET => { + let g = state.draft["gender"].as_str().unwrap_or("male"); + if let Some(p) = parts.library.defaults.get(g) { + state.draft = p.clone(); + state.redraw = true; + } + } + i => { + state.selected = visible.iter().position(|v| *v == i).unwrap_or(0); + action = Some((i, 0)); + } + } + } + } + } + if mouse.just_pressed(MouseButton::Left) { + for (interaction, adjust) in &adjustments { + if *interaction == Interaction::Pressed { + state.selected = visible.iter().position(|i| *i == adjust.0).unwrap_or(0); + action = Some((adjust.0, adjust.1)); + } + } + } + if back { + state.search.clear(); + if let Some(i) = state.path.pop() { + state.selected = state.visible().iter().position(|v| *v == i).unwrap_or(0); + } else { + let save = (|| -> Result<(), String> { + std::fs::create_dir_all(state.settings.parent().unwrap()) + .map_err(|e| e.to_string())?; + let tmp = state.settings.with_extension("pending.json"); + std::fs::write( + &tmp, + serde_json::to_vec_pretty(&state.draft).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + std::fs::rename(tmp, &state.settings).map_err(|e| e.to_string()) + })(); + match save { + Ok(()) => { + state.open = false; + pause.open = false; + } + Err(e) => state.status = format!("Could not save: {e}"), + } + } + 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.search.clear(); + state.path.push(i); + state.selected = 0; + state.status.clear(); + } else if let Some(note) = entry.note { + state.status = note; + } else { + let mut profile = state.draft.clone(); + if let Some(key) = entry.scalar { + let amount = if direction < 0 { -1. } else { 1. }; + let val = (scalar(&profile, &key, entry.initial.unwrap_or(0.)) + + amount * entry.step.unwrap_or(0.1)) + .clamp(entry.minimum.unwrap_or(0.), entry.maximum.unwrap_or(1.)); + if let Some((p, c)) = key.split_once('.') { + profile[p][c] = json!(val); + } else { + profile[&key] = json!(val); + } + } else { + let patch = if entry.options.is_empty() { + entry.patch + } else { + let current = option_index(&entry, &profile); + let index = match current { + Some(i) => (i as i32 + if direction == 0 { 1 } else { direction }) + .rem_euclid(entry.options.len() as i32) + as usize, + None => 0, + }; + entry.options[index].patch.clone() + }; + if let Some(patch) = patch { + if let Some(gender) = patch["gender"].as_str() { + if let Some(default) = parts.library.defaults.get(gender) { + profile = default.clone(); + } + } else { + merge(&mut profile, &patch); + if let Some(hair) = patch["selections"].get("Hair") { + profile["hair_choice"] = hair.clone(); + } + } + } + } + match parts.resolve(&profile) { + Ok(p) => { + state.draft = p; + state.status.clear(); + } + Err(e) => state.status = e, + } + } + state.redraw = true; + } +} +fn preferences( + state: Res, + mut physics: ResMut, + mut skater: ResMut, +) { + apply_preferences(&state.draft, &mut physics, &mut skater.animation); +} +pub(crate) fn apply_preferences( + profile: &Value, + physics: &mut crate::physics::GamePhysics, + animation: &mut crate::skater_animation::SkaterAnimation, +) { + physics.set_equipment_preferences( + scalar(profile, "truck", 0.7) as f32, + scalar(profile, "wheel", 0.7) as f32, + ); + physics.set_gesture_preferences(profile["gestures"].as_object().map(|g| { + // ResetGestureSet824FA730 marks all 37 entries available and selects + // the first four in table order for Up,Down,Left,Right. + std::array::from_fn(|i| { + g.get(&i.to_string()) + .and_then(Value::as_u64) + .unwrap_or(i as u64) as u32 + }) + })); + animation.set_customisation( + scalar(profile, "stance", 1.) as u32, + scalar(profile, "style", 0.) as u32, + ); + animation + .motion + .animation + .posture + .set_profile(scalar(profile, "posture", 0.) as u32); +} +fn draw( + mut commands: Commands, + mut state: ResMut, + window: Single<&Window>, + mut root: Single<(Entity, &mut Node), With>, +) { + root.1.display = if state.open { + Display::Flex + } else { + Display::None + }; + if !state.open { + return; + } + let size = (((window.height() * 0.92 - 255.) / 90.).floor() as usize).clamp(1, 10); + if size != state.page_size { + state.page_size = size; + state.redraw = true; + } + if !state.redraw { + return; + } + state.redraw = false; + commands.entity(root.0).despawn_children(); + let page = state.page(); + let visible = state.visible(); + let start = state.selected / state.page_size * state.page_size; + let subtitle = if !state.search.is_empty() { + format!("Search: {}", state.search) + } else if state.path.is_empty() { + "Make it yours".to_owned() + } else { + let mut p = &state.index; + let mut crumbs = vec![]; + for &i in &state.path { + crumbs.push(p.label.clone()); + p = &p.children[i]; + } + crumbs.join(" / ") + }; + commands.entity(root.0).with_children(|p| { + p.spawn(( + Text::new(subtitle), + TextFont { + font_size: 13., + ..default() + }, + TextColor(Color::srgb(0.48, 0.62, 0.68)), + )); + p.spawn(( + Text::new(&page.label), + TextFont { + font_size: 29., + ..default() + }, + TextColor(Color::WHITE), + )); + p.spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: px(6), + flex_grow: 1., + min_height: px(0), + ..default() + }) + .with_children(|list| { + if visible.is_empty() { + list.spawn(( + Text::new("No matches. Esc clears your search."), + TextFont { + font_size: 16., + ..default() + }, + TextColor(Color::WHITE), + )); + } + for (pos, &i) in visible.iter().enumerate().skip(start).take(state.page_size) { + let e = &page.children[i]; + let active = option_index(e, &state.draft); + let detail = if let Some(key) = &e.scalar { + let v = scalar(&state.draft, key, e.initial.unwrap_or(0.)); + format!( + "{}% − / +", + ((v - e.minimum.unwrap_or(0.)) + / (e.maximum.unwrap_or(1.) - e.minimum.unwrap_or(0.)) + * 100.) + .round() as i32 + ) + } else if !e.options.is_empty() { + let index = active.unwrap_or(0); + format!( + "{} · {}/{}", + e.options[index].label, + index + 1, + e.options.len() + ) + } else if !e.children.is_empty() { + String::new() + } else if e.patch.is_some() { + if e.patch + .as_ref() + .is_some_and(|p| matches_patch(&state.draft, p)) + { + "Selected".into() + } else { + "Select".into() + } + } else { + String::new() + }; + let adjustable = e.scalar.is_some() || e.options.len() > 1; + let width = (window.width() * 0.38).clamp(370., 520.) + - 44. + - 24. + - if adjustable { 68. } else { 0. }; + let label = format!( + "{}{}", + e.label, + if !e.children.is_empty() { " ›" } else { "" } + ); + let label_size = (width / (label.chars().count().max(1) as f32 * 0.64)).clamp(16., 18.); + let detail_size = (width / (detail.chars().count().max(1) as f32 * 0.64)).clamp(10., 12.); + list.spawn(( + Node { + width: percent(100), + height: px(84), + flex_shrink: 0., + align_items: AlignItems::Stretch, + border_radius: BorderRadius::all(px(9)), + ..default() + }, + BackgroundColor(if pos == state.selected { + Color::srgb(0.08, 0.27, 0.29) + } else { + Color::srgb(0.045, 0.065, 0.085) + }), + )) + .with_children(|r| { + if adjustable { + adjust_button(r, i, -1, "−"); + } + r.spawn(( + Button, + Row(i), + Node { + flex_grow: 1., + min_width: px(0), + padding: UiRect::axes(px(12), px(7)), + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + ..default() + }, + )) + .with_children(|text| { + text.spawn(( + Text::new(label), + TextFont { + font_size: label_size, + ..default() + }, + TextColor(Color::WHITE), + )); + if !detail.is_empty() { + text.spawn(( + Text::new(detail), + TextFont { + font_size: detail_size, + ..default() + }, + TextColor(Color::srgb(0.60, 0.76, 0.78)), + )); + } + }); + if adjustable { + adjust_button(r, i, 1, "+"); + } + }); + } + }); + if !state.status.is_empty() { + p.spawn(( + Text::new(&state.status), + TextFont { + font_size: 13., + ..default() + }, + TextColor(Color::srgb(1., 0.72, 0.4)), + )); + } + p.spawn(Node { + width: percent(100), + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + ..default() + }) + .with_children(|footer| { + for (id, title) in [ + ( + BACK, + if state.path.is_empty() { + "Done" + } else { + "‹ Back" + }, + ), + (PREV, "‹"), + (NEXT, "›"), + ] { + if id != BACK && visible.len() <= state.page_size { + continue; + } + footer + .spawn(( + Button, + Row(id), + Node { + padding: UiRect::axes(px(14), px(8)), + border_radius: BorderRadius::all(px(8)), + ..default() + }, + BackgroundColor(if id == BACK { + Color::srgb(0.12, 0.39, 0.38) + } else { + Color::srgb(0.07, 0.10, 0.13) + }), + )) + .with_children(|r| { + r.spawn(( + Text::new(title), + TextFont { + font_size: 17., + ..default() + }, + TextColor(Color::WHITE), + )); + }); + } + if visible.len() > state.page_size { + footer.spawn(( + Text::new(format!( + "{} / {}", + start / state.page_size + 1, + visible.len().div_ceil(state.page_size) + )), + TextFont { + font_size: 13., + ..default() + }, + TextColor(Color::srgb(0.6, 0.7, 0.75)), + )); + } + }); + p.spawn(( + Text::new(if state.path.is_empty() { + "Right stick: Rotate • Done saves and resumes." + } else { + "↑↓ Browse ←→ Change Right stick: Rotate +Type to search" + }), + TextFont { + font_size: 12., + ..default() + }, + TextColor(Color::srgb(0.48, 0.60, 0.66)), + )); + }); +} + +fn adjust_button(parent: &mut ChildSpawnerCommands, index: usize, direction: i32, label: &str) { + parent + .spawn(( + Button, + Adjust(index, direction), + Node { + width: px(34), + flex_shrink: 0., + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..default() + }, + )) + .with_children(|p| { + p.spawn(( + Text::new(label), + TextFont { + font_size: 22., + ..default() + }, + TextColor(Color::srgb(0.7, 0.9, 0.9)), + )); + }); +} +fn matches_patch(profile: &Value, patch: &Value) -> bool { + if let Some(object) = patch.as_object() { + object.iter().all(|(k, v)| matches_patch(&profile[k], v)) + } else { + profile == patch + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn customiser_colour_choice_is_distinct_from_the_original() { + let entry = Entry { + options: vec![ + choice( + "Original", + json!({"selections":{"Feet":{"asset_id":"shoe","material_id":"new"}},"colours":{"Feet":null}}), + ), + choice( + "Red", + json!({"selections":{"Feet":{"asset_id":"shoe","material_id":"new"}},"colours":{"Feet":[1.,0.,0.]}}), + ), + ], + ..default() + }; + let mut p = json!({"selections":{"Feet":{"asset_id":"shoe","material_id":"new"}}}); + assert_eq!(option_index(&entry, &p), Some(0)); + merge(&mut p, entry.options[1].patch.as_ref().unwrap()); + assert_eq!(option_index(&entry, &p), Some(1)); + } + #[test] + fn customiser_owned_menu_presets_tattoos_and_search() { + let Ok(path) = std::env::var("SKATE_CAC_TEST_LIBRARY") else { + return; + }; + let lib: Library = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let extras: Vec = serde_json::from_slice( + &std::fs::read(std::path::Path::new(&path).with_file_name("extra-menu.json")).unwrap(), + ) + .unwrap(); + let parts = Parts::for_test(lib); + for gender in ["male", "female"] { + let mut state = Customiser { + open: true, + enabled: true, + just_opened: false, + preview_yaw: 0., + index: menu(&parts.library, extras.clone()), + path: vec![], + selected: 0, + page_size: 6, + search: String::new(), + draft: parts.library.defaults[gender].clone(), + settings: PathBuf::new(), + status: String::new(), + redraw: true, + }; + assert_eq!(state.visible().len(), 4); + let body = 0; + let tattoos = state.index.children[body] + .children + .iter() + .position(|e| e.label == "Tattoos") + .unwrap(); + let original = state.draft.clone(); + for (i, slot) in [(0, "OuterTorso"), (1, "Pants")] { + state.path = vec![body, tattoos, i]; + let preview = state.preview(&parts); + assert_ne!(preview["selections"][slot], original["selections"][slot]); + assert_eq!(state.draft, original); + } + state.open = false; + assert_eq!(state.preview(&parts), original); + state.open = true; + let faces = state.index.children[body] + .children + .iter() + .position(|e| e.label == "Face presets") + .unwrap(); + state.path = vec![body, faces]; + assert_eq!(state.visible().len(), 10); + for i in state.visible() { + let mut profile = state.draft.clone(); + merge( + &mut profile, + state.page().children[i].patch.as_ref().unwrap(), + ); + assert!(parts.resolve(&profile).is_ok()); + } + state.search = "Face 10".into(); + assert_eq!(state.visible().len(), 1); + state.search = "No such item".into(); + assert_eq!(state.visible().len(), 0); + // Selecting a new face/body profile must survive JSON save/reload. + let encoded = serde_json::to_vec(&state.draft).unwrap(); + let restored: Value = serde_json::from_slice(&encoded).unwrap(); + assert_eq!( + parts.resolve(&restored).unwrap(), + parts.resolve(&state.draft).unwrap() + ); + } + } +} diff --git a/crates/skate-game/src/customiser_material.rs b/crates/skate-game/src/customiser_material.rs new file mode 100644 index 00000000..0c3b008a --- /dev/null +++ b/crates/skate-game/src/customiser_material.rs @@ -0,0 +1,182 @@ +//! Skin stamps modify albedo before the unchanged StandardMaterial lighting. +use bevy::{ + asset::embedded_asset, + pbr::{ExtendedMaterial, MaterialExtension}, + prelude::*, + render::render_resource::AsBindGroup, + shader::ShaderRef, +}; + +pub(crate) type SkaterMaterial = ExtendedMaterial; + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] +pub(crate) struct SkinStamp { + #[texture(100)] + #[sampler(101)] + pub texture: Option>, + /// cac_hair_defaultPS samples opacity.r on TEXCOORD1, independently of diffuse alpha. + #[texture(103)] + #[sampler(104)] + pub hair_opacity: Option>, + /// Scale.xy, translation.zw in the authored secondary UV stream. + #[uniform(102)] + pub transform: Vec4, + /// min.xy, max.zw of the selected arm/leg's stampable rectangle. + #[uniform(102)] + pub rectangle: Vec4, + #[uniform(102)] + pub enabled: Vec4, +} +impl MaterialExtension for SkinStamp { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/customiser_stamp.wgsl".into() + } + fn prepass_fragment_shader() -> ShaderRef { + "embedded://skate3rust/customiser_prepass.wgsl".into() + } + fn deferred_fragment_shader() -> ShaderRef { + Self::fragment_shader() + } +} +pub(crate) fn register(app: &mut App) { + embedded_asset!(app, "customiser_stamp.wgsl"); + embedded_asset!(app, "customiser_prepass.wgsl"); + app.add_plugins(MaterialPlugin::::default()); +} + +pub(crate) fn placement(target: [f32; 4], art: [f32; 4]) -> (Vec4, Vec4) { + // Retail rectangles are [top, bottom, left, right], with V increasing + // upwards. Texture pixels and glTF use downward V. Artwork pixel bounds + // and both limb meshes corroborate this conversion. + let target_min = Vec2::new(target[2], 1. - target[0]); + let target_max = Vec2::new(target[3], 1. - target[1]); + let art_min = Vec2::new(art[2], 1. - art[0]); + let art_max = Vec2::new(art[3], 1. - art[1]); + let scale = (art_max - art_min) / (target_max - target_min); + let offset = art_min - target_min * scale; + ( + Vec4::new(scale.x, scale.y, offset.x, offset.y), + Vec4::new(target_min.x, target_min.y, target_max.x, target_max.y), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn customiser_stamp_maps_authored_borders() { + let (t, r) = placement([0.5, 0.0, 0.1, 0.4], [0.9, 0.1, 0.2, 0.8]); + assert!((Vec2::new(r.x, r.y) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.2, 0.1), 1e-6)); + assert!((Vec2::new(r.z, r.w) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.8, 0.9), 1e-6)); + assert_eq!( + bevy::asset::embedded_path!("customiser_stamp.wgsl"), + std::path::PathBuf::from("skate3rust/customiser_stamp.wgsl") + ); + } + #[test] + fn customiser_shader_compiles_offline() { + use bevy::render::render_resource::{DownlevelFlags, WgpuFeatures}; + use bevy::shader::{ShaderCache, ShaderDefVal}; + let Ok(path) = std::env::var("SKATE_CAC_TEST_BEVY_SOURCES") else { + return; + }; + fn walk(path: &std::path::Path, result: &mut Vec) { + for entry in std::fs::read_dir(path).unwrap().flatten() { + let p = entry.path(); + if p.is_dir() { + walk(&p, result); + } else if p.extension().is_some_and(|e| e == "wgsl") { + result.push(p); + } + } + } + let mut files = vec![]; + for entry in std::fs::read_dir(path).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("bevy_") && name.ends_with("-0.18.1") { + walk(&entry.path().join("src"), &mut files); + } + } + let mut assets = Assets::::default(); + let mut cache = + ShaderCache::<(), ()>::new(WgpuFeatures::empty(), DownlevelFlags::all(), |_, _, _| { + Ok(()) + }); + let defs: Vec<_> = [ + ("MAX_DIRECTIONAL_LIGHTS", 10), + ("MAX_CASCADES_PER_LIGHT", 4), + ("MAX_VIEW_LIGHT_PROBES", 8), + ("PER_OBJECT_BUFFER_BATCH_SIZE", 64), + ("MATERIAL_BIND_GROUP", 3), + ("AVAILABLE_STORAGE_BUFFER_BINDINGS", 8), + ] + .into_iter() + .map(|(n, v)| ShaderDefVal::UInt(n.into(), v)) + .collect(); + for path in files { + let source = std::fs::read_to_string(&path).unwrap(); + if !source.contains("#define_import_path") { + continue; + } + let shader = Shader::from_wgsl_with_defs( + source, + path.to_string_lossy().to_string(), + defs.clone(), + ); + let h = assets.add(shader.clone()); + cache.set_shader(h.id(), shader); + } + for (source, path, prepass) in [ + ( + include_str!("customiser_stamp.wgsl"), + "customiser_stamp.wgsl", + false, + ), + ( + include_str!("customiser_prepass.wgsl"), + "customiser_prepass.wgsl", + true, + ), + ] { + let shader = Shader::from_wgsl(source, path); + let h = assets.add(shader.clone()); + cache.set_shader(h.id(), shader); + for secondary_uv in [false, true] { + let mut d = defs.clone(); + for name in [ + "VERTEX_UVS", + "MAY_DISCARD", + "VERTEX_UVS_A", + "VERTEX_OUTPUT_INSTANCE_INDEX", + "VERTEX_NORMALS", + "MESH_PIPELINE", + "STANDARD_MATERIAL", + "STANDARD_MATERIAL_BASE_COLOR_TEXTURE", + ] { + d.push(ShaderDefVal::Bool(name.into(), true)); + } + if secondary_uv { + d.push(ShaderDefVal::Bool("VERTEX_UVS_B".into(), true)); + } + if prepass { + d.push(ShaderDefVal::Bool("PREPASS_PIPELINE".into(), true)); + } + cache + .get(&(), 0, h.id(), &d) + .unwrap_or_else(|e| panic!("{path} secondary UV {secondary_uv}: {e:?}")); + if prepass { + for name in [ + "PREPASS_FRAGMENT", + "NORMAL_PREPASS", + "NORMAL_PREPASS_OR_DEFERRED_PREPASS", + ] { + d.push(ShaderDefVal::Bool(name.into(), true)); + } + cache + .get(&(), 0, h.id(), &d) + .unwrap_or_else(|e| panic!("Normal {path} UV {secondary_uv}: {e:?}")); + } + } + } + } +} diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs new file mode 100644 index 00000000..75b24086 --- /dev/null +++ b/crates/skate-game/src/customiser_parts.rs @@ -0,0 +1,805 @@ +//! Resident retail parts: selection changes visibility/materials; morphs stay on GPU. +use crate::customiser_material::{SkaterMaterial, SkinStamp}; +use bevy::{gltf::Gltf, mesh::morph::MorphWeights, prelude::*}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Deserialize)] +pub(crate) struct Part { + pub slot: String, + pub name: String, + pub flags: HashMap, + pub materials: Vec, + pub scene: String, +} +impl Part { + pub fn flag(&self, key: &str) -> &str { + self.flags.get(key).map(String::as_str).unwrap_or("") + } +} +#[derive(Clone, Deserialize)] +pub(crate) struct Material { + pub name: String, + pub flags: HashMap, + diffuse: String, + normal: Option, + rough: Option, + alpha: bool, + #[serde(default)] + opacity: Option, + tint: [f32; 3], + metallic: f32, + roughness: f32, +} +impl Material { + pub fn flag(&self, key: &str) -> &str { + self.flags.get(key).map(String::as_str).unwrap_or("") + } +} +#[derive(Clone, Deserialize)] +pub(crate) struct Colour { + pub name: String, + pub rgb: [f32; 3], +} +#[derive(Clone, Deserialize)] +pub(crate) struct Tattoo { + pub name: String, + pub texture: String, + pub bounds: [f32; 4], +} +#[derive(Default, Deserialize)] +pub(crate) struct Library { + pub models: HashMap, + pub materials: HashMap, + pub defaults: HashMap, + pub morphs: Vec, + #[serde(default)] + pub tattoos: HashMap, + #[serde(default)] + pub colours: Vec, +} +#[derive(Resource, Default)] +pub(crate) struct Parts { + pub library: Library, + geometry: HashMap>, + materials: HashMap, Vec>)>, + instances: HashMap, + tattoos: HashMap>, + pub applied: Value, +} +#[derive(Component)] +pub(crate) struct PartRoot(pub String); +impl Parts { + #[cfg(test)] + pub fn for_test(library: Library) -> Self { + Self { + library, + ..default() + } + } + pub fn resolve(&self, profile: &Value) -> Result { + let lib = &self.library; + if let Some(gender) = profile["gender"].as_str() { + if !["male", "female"].contains(&gender) { + return Err("Invalid body".into()); + } + } + for (name, max) in [ + ("truck", 1.), + ("wheel", 1.), + ("stance", 1.), + ("style", 3.), + ("posture", 3.), + ] { + if let Some(value) = profile.get(name) { + if value.as_f64().is_none_or(|v| !(0.0..=max).contains(&v)) { + return Err(format!("Invalid {name}")); + } + } + } + for key in ["skin_tint", "hair_tint"] { + if let Some(value) = profile.get(key) { + let v = serde_json::from_value::<[f32; 3]>(value.clone()) + .map_err(|_| "Invalid colour")?; + if v.iter().any(|v| !(0.0..=1.0).contains(v)) { + return Err("Invalid colour".into()); + } + } + } + if let Some(weights) = profile["morphs"].as_object() { + if weights.iter().any(|(key, v)| { + !lib.morphs.contains(key) || v.as_f64().is_none_or(|v| !(0.0..=0.5).contains(&v)) + }) { + return Err("Invalid face or body value".into()); + } + } + if let Some(gestures) = profile["gestures"].as_object() { + if gestures.iter().any(|(key, v)| { + !["0", "1", "2", "3"].contains(&key.as_str()) || v.as_u64().is_none_or(|v| v >= 37) + }) { + return Err("Invalid gesture".into()); + } + } + if let Some(zones) = profile["tattoos"].as_object() { + for (zone, v) in zones { + if !["Arm", "Leg"].contains(&zone.as_str()) + || v["id"] + .as_str() + .is_some_and(|id| !lib.tattoos.contains_key(id)) + || v["side"] + .as_u64() + .is_some_and(|v| v > if zone == "Arm" { 3 } else { 1 }) + { + return Err("Invalid tattoo".into()); + } + } + } + + let mut result = profile.clone(); + let gender = profile["gender"].as_str().unwrap_or("male"); + if let Some(hair) = profile.get("hair_choice") { + result["selections"]["Hair"] = hair.clone(); + } + let selections = result["selections"] + .as_object_mut() + .ok_or("No outfit selected")?; + selections.retain(|_, v| !v.is_null()); + for (slot, v) in selections.iter() { + let id = v["asset_id"].as_str().ok_or("Invalid item")?; + let part = lib.models.get(id).ok_or("Item unavailable")?; + if part.slot != *slot || !["", gender, "unisex"].contains(&part.flag("Gender")) { + return Err("Choose an item for this body".into()); + } + let mid = v["material_id"].as_str().ok_or("Invalid colour")?; + if !part.materials.iter().any(|s| s == mid) || !lib.materials.contains_key(mid) { + return Err("Colour unavailable".into()); + } + } + let model = |s: &serde_json::Map, slot: &str| { + s.get(slot) + .and_then(|v| v["asset_id"].as_str()) + .and_then(|id| lib.models.get(id)) + }; + let choose = |s: &mut serde_json::Map, + slot: &str, + key: &str, + value: &str| + -> Result<(), String> { + if value.is_empty() || value == "none" { + s.remove(slot); + return Ok(()); + } + if model(s, slot).is_some_and(|p| p.flag(key) == value) { + return Ok(()); + } + let mut candidates: Vec<_> = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == slot + && ["", gender, "unisex"].contains(&p.flag("Gender")) + && p.flag(key) == value + }) + .collect(); + candidates.sort_by_key(|(id, _)| *id); + let (id, p) = candidates + .first() + .ok_or_else(|| format!("No matching {}", slot))?; + let mid = p + .materials + .iter() + .find(|id| { + lib.materials + .get(*id) + .is_some_and(|m| m.flag("IsDefault") == "yes") + }) + .or_else(|| { + p.materials + .iter() + .find(|id| lib.materials.contains_key(*id)) + }) + .ok_or("No matching material")?; + s.insert( + slot.into(), + serde_json::json!({"asset_id":id,"material_id":mid}), + ); + Ok(()) + }; + if let Some(outer) = model(selections, "OuterTorso") { + choose( + selections, + "Arm", + "ArmModelType", + outer.flag("ArmModelRequired"), + )?; + choose( + selections, + "InnerTorso", + "InnerCutType", + outer.flag("InnerCutTypeRequired"), + )?; + if outer.flag("SupportsWristItem") == "no" { + selections.remove("WristItem"); + } + if outer.flag("IsNecklaceRemoved") == "yes" { + selections.remove("Jewellery"); + } + } + if let Some(pants) = model(selections, "Pants") { + choose( + selections, + "Leg", + "LegModelType", + pants.flag("LegModelRequired"), + )?; + } + if let Some(leg) = model(selections, "Leg") { + let sock_style = selections + .get("Sock") + .and_then(|v| v["material_id"].as_str()) + .and_then(|id| lib.materials.get(id)) + .map(|m| m.flag("SockStyle")) + .unwrap_or("none"); + let desired = if sock_style == "none" { "" } else { sock_style }; + if leg.flag("RequiresSockStyle") != desired + && !(desired.is_empty() && leg.flag("RequiresSockStyle") == "none") + { + let mut matches: Vec<_> = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == "Leg" + && ["", gender, "unisex"].contains(&p.flag("Gender")) + && p.flag("LegModelType") == leg.flag("LegModelType") + && (p.flag("RequiresSockStyle") == desired + || (desired.is_empty() && p.flag("RequiresSockStyle") == "none")) + }) + .collect(); + matches.sort_by_key(|(id, _)| *id); + let (id, p) = matches.first().ok_or("Choose another sock length")?; + selections.insert( + "Leg".into(), + serde_json::json!({"asset_id":id,"material_id":p.materials[0]}), + ); + } + } + if !selections.contains_key("Hat") { + if let Some(hair) = model(selections, "Hair") { + if hair.flag("HairModelType") != "full" { + let candidate = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == "Hair" + && p.flag("HairStyle") == hair.flag("HairStyle") + && p.flag("Gender") == hair.flag("Gender") + && p.flag("HairModelType") == "full" + }) + .min_by_key(|(id, _)| *id); + if let Some((id, p)) = candidate { + selections.insert( + "Hair".into(), + serde_json::json!({"asset_id":id,"material_id":p.materials[0]}), + ); + } + } + } + } + if let Some(hat) = model(selections, "Hat") { + let required = hat.flag("RequiresHairModelType"); + if required == "none" { + selections.remove("Hair"); + } else if let Some(hair) = model(selections, "Hair") { + if !required.is_empty() && hair.flag("HairModelType") != required { + let matching = lib + .models + .iter() + .filter(|(_, p)| { + p.slot == "Hair" + && p.flag("HairStyle") == hair.flag("HairStyle") + && p.flag("HairModelType") == required + && ["", gender, "unisex"].contains(&p.flag("Gender")) + }) + .min_by_key(|(id, _)| *id); + if let Some((id, p)) = matching { + selections.insert( + "Hair".into(), + serde_json::json!({"asset_id":id,"material_id":p.materials[0]}), + ); + } else { + selections.remove("Hair"); + } + } + } + } + let removals: Vec = selections + .keys() + .flat_map(|s| { + model(selections, s).into_iter().flat_map(|p| { + p.flag("RequiresRemovalOfComponents") + .split(',') + .map(|s| s.trim().to_owned()) + }) + }) + .filter(|s| !s.is_empty()) + .collect(); + for s in removals { + selections.remove(&s); + } + let shadow = selections + .get("Hair") + .and_then(|v| v["asset_id"].as_str()) + .and_then(|id| lib.models.get(id)) + .map(|p| { + if p.flag("RequiresHairlineShadowStyle") == "on" { + "on" + } else { + "" + } + }) + .unwrap_or(""); + let head = selections + .get("Rostral") + .and_then(|v| v["material_id"].as_str()) + .and_then(|id| lib.materials.get(id)) + .ok_or("Head unavailable")?; + let tone = profile["skin"].as_str().unwrap_or(head.flag("SkinTone")); + let beard = if gender == "female" { + "" + } else { + profile["beard"] + .as_str() + .unwrap_or(head.flag("FacialHairStyle")) + }; + for (slot, value) in selections.iter_mut() { + let old_id = value["material_id"].as_str().unwrap(); + let old = &lib.materials[old_id]; + if old.flag("SkinTone").is_empty() { + continue; + } + let part = &lib.models[value["asset_id"].as_str().unwrap()]; + let best = part + .materials + .iter() + .filter_map(|id| lib.materials.get(id).map(|m| (id, m))) + .filter(|(_, m)| { + m.flag("SkinTone") == tone + && (slot != "Rostral" || m.flag("FacialHairStyle") == beard) + }) + .min_by_key(|(id, m)| { + let shadow_penalty = + if slot == "Rostral" && m.flag("HairlineShadowStyle") != shadow { + 100 + } else { + 0 + }; + shadow_penalty + + if *id == old_id { + 0 + } else { + 1 + old + .flags + .iter() + .filter(|(k, v)| { + k.as_str() != "SkinTone" + && k.as_str() != "FacialHairStyle" + && m.flags.get(*k) != Some(*v) + }) + .count() + } + }); + let (id, _) = best.ok_or("This skin and facial hair combination is unavailable")?; + value["material_id"] = Value::String(id.clone()); + } + Ok(result) + } + pub fn warm(&mut self, id: &str, server: &AssetServer, materials: &mut Assets) { + if self.materials.contains_key(id) { + return; + } + let Some(m) = self.library.materials.get(id) else { + return; + }; + let mut images = vec![]; + let mut load = |path: &String, linear: bool| { + let handle = server.load_with_settings( + path.clone(), + move |s: &mut bevy::image::ImageLoaderSettings| { + s.is_srgb = !linear; + }, + ); + images.push(handle.clone()); + handle + }; + let diffuse = load(&m.diffuse, false); + let normal = m.normal.as_ref().map(|p| load(p, true)); + let rough = m.rough.as_ref().map(|p| load(p, true)); + let opacity = m.opacity.as_ref().map(|p| load(p, true)); + let material = materials.add(SkaterMaterial { + base: StandardMaterial { + base_color: Color::srgb(m.tint[0], m.tint[1], m.tint[2]), + base_color_texture: Some(diffuse), + normal_map_texture: normal, + metallic_roughness_texture: rough, + double_sided: m.opacity.is_some(), + cull_mode: if m.opacity.is_some() { + None + } else { + Some(bevy::render::render_resource::Face::Back) + }, + metallic: m.metallic, + perceptual_roughness: if m.rough.is_some() { 1. } else { m.roughness }, + alpha_mode: if m.alpha { + AlphaMode::Mask(0.5) + } else { + AlphaMode::Opaque + }, + ..default() + }, + extension: SkinStamp { + hair_opacity: opacity, + enabled: if m.opacity.is_some() { + Vec4::Y + } else { + Vec4::ZERO + }, + ..default() + }, + }); + self.materials.insert(id.into(), (material, images)); + } +} +pub(crate) fn setup( + mut commands: Commands, + config: Res, + server: Res, +) { + let library = std::fs::read( + config + .asset_root + .join("private/customisation/library-v3.json"), + ) + .or_else(|_| std::fs::read(config.asset_root.join("private/customisation/library.json"))) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .unwrap_or_default(); + let geometry = library + .models + .iter() + .map(|(id, p)| (id.clone(), server.load(p.scene.clone()))) + .collect(); + let tattoos = library + .tattoos + .iter() + .map(|(id, t)| { + ( + id.clone(), + server.load_with_settings( + t.texture.clone(), + |s: &mut bevy::image::ImageLoaderSettings| { + s.is_srgb = false; + }, + ), + ) + }) + .collect(); + commands.insert_resource(Parts { + tattoos, + library, + geometry, + ..default() + }); +} +pub(crate) fn update( + mut commands: Commands, + server: Res, + mut parts: ResMut, + mut state: ResMut, + mut materials: ResMut>, + root: Query>, + parents: Query<&ChildOf>, + mut scenes: Query<(Entity, Option<&PartRoot>, &mut Visibility), With>, + mut mesh_materials: Query<(Entity, Option<&mut MeshMaterial3d>), With>, + mut morphs: Query<(Entity, &mut MorphWeights)>, + mut animation: ResMut, +) { + if !state.enabled { + return; + } + let Ok(root) = root.single() else { + return; + }; + let preview = state.preview(&parts); + let Some(selections) = preview["selections"].as_object() else { + return; + }; + let desired: Vec<(String, String)> = selections + .values() + .filter_map(|v| { + Some(( + v["asset_id"].as_str()?.into(), + v["material_id"].as_str()?.into(), + )) + }) + .collect(); + if parts.applied == preview && !state.open { + return; + } + // Resolve the visible choices ahead of selection, including skin, head, + // sleeves, and sock dependencies. No conversion process runs in the game. + let precache: HashSet<(String, String)> = state + .preload_outfits(&parts) + .iter() + .flat_map(|p| { + p["selections"] + .as_object() + .into_iter() + .flat_map(|s| s.values()) + }) + .filter_map(|v| { + Some(( + v["asset_id"].as_str()?.to_owned(), + v["material_id"].as_str()?.to_owned(), + )) + }) + .chain(desired.iter().cloned()) + .collect(); + for (_, mid) in &precache { + parts.warm(mid, &server, &mut materials); + } + let precache: HashSet = precache.into_iter().map(|(id, _)| id).collect(); + for id in precache { + if parts.instances.contains_key(&id) { + continue; + } + if let Some(handle) = parts.geometry.get(&id) { + if server.is_loaded_with_dependencies(handle.id()) { + let scene = parts.library.models[&id].scene.clone(); + let entity = commands + .spawn(( + PartRoot(id.clone()), + Visibility::Hidden, + SceneRoot(server.load(GltfAssetLabel::Scene(0).from_asset(scene))), + )) + .id(); + commands.entity(root).add_child(entity); + parts.instances.insert(id, entity); + } + } + } + if parts.applied == preview { + return; + } + let mut ready = true; + for (id, mid) in &desired { + parts.warm(mid, &server, &mut materials); + if !parts.instances.contains_key(id) { + ready = false; + } + let part = &parts.library.models[id]; + let slot = if part.slot == "OuterTorso" && part.flag("TopType").is_empty() { + "Arm" + } else { + &part.slot + }; + if let Some(tattoo) = state.draft["tattoos"][slot]["id"].as_str() { + if parts + .tattoos + .get(tattoo) + .is_none_or(|h| !server.is_loaded_with_dependencies(h.id())) + { + ready = false; + } + } + if !parts.materials.get(mid).is_some_and(|(_, images)| { + images + .iter() + .all(|h| server.is_loaded_with_dependencies(h.id())) + }) { + ready = false; + } + if let Some(&entity) = parts.instances.get(id) { + if !mesh_materials + .iter() + .any(|(e, _)| parents.iter_ancestors(e).any(|p| p == entity)) + { + ready = false; + } + } + } + if !ready { + return; + } + let wanted: HashSet<_> = desired.iter().map(|(id, _)| id.as_str()).collect(); + let mut rebind = false; + for (entity, part, mut visible) in &mut scenes { + if !parents.iter_ancestors(entity).any(|p| p == root) { + continue; + } + let target = if part.is_some_and(|p| wanted.contains(p.0.as_str())) { + Visibility::Inherited + } else { + Visibility::Hidden + }; + if *visible != target { + *visible = target; + rebind = true; + } + } + for (id, mid) in &desired { + let entity = parts.instances[id]; + let handle = parts.materials[mid].0.clone(); + let part = &parts.library.models[id]; + let source = &parts.library.materials[mid]; + let tint = if !source.flag("SkinTone").is_empty() { + state.draft.get("skin_tint") + } else if part.slot == "Hair" { + state.draft.get("hair_tint") + } else { + state.draft["colours"].get(&part.slot) + }; + let rgb = tint + .and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()) + .unwrap_or(source.tint); + let color = Color::srgb(rgb[0], rgb[1], rgb[2]); + if materials + .get(&handle) + .is_some_and(|m| m.base.base_color != color) + { + if let Some(m) = materials.get_mut(&handle) { + m.base.base_color = color; + } + } + let slot = if part.slot == "OuterTorso" && part.flag("TopType").is_empty() { + "Arm" + } else { + &part.slot + }; + let stamp = &state.draft["tattoos"][slot]; + let selected = stamp["id"] + .as_str() + .and_then(|id| parts.library.tattoos.get(id).map(|t| (id, t))); + let mut extension = SkinStamp { + hair_opacity: materials + .get(&handle) + .and_then(|m| m.extension.hair_opacity.clone()), + enabled: if source.opacity.is_some() { + Vec4::Y + } else { + Vec4::ZERO + }, + ..default() + }; + if let Some((tid, t)) = selected { + let key = match stamp["side"].as_u64().unwrap_or(0) { + 1 => "StampUVConstraintQ4", + 2 => "StampUVConstraintQ1", + 3 => "StampUVConstraintQ2", + _ => "StampUVConstraintQ3", + }; + let bounds: Vec = part + .flag(key) + .split(',') + .filter_map(|n| n.parse().ok()) + .collect(); + if let Ok(bounds) = <[f32; 4]>::try_from(bounds) { + let (transform, rectangle) = + crate::customiser_material::placement(bounds, t.bounds); + extension = SkinStamp { + texture: parts.tattoos.get(tid).cloned(), + transform, + rectangle, + enabled: Vec4::X, + ..default() + }; + } + } + let changed = materials.get(&handle).is_some_and(|m| { + m.extension.texture != extension.texture + || m.extension.transform != extension.transform + || m.extension.enabled != extension.enabled + }); + if changed { + if let Some(m) = materials.get_mut(&handle) { + m.extension = extension; + } + } + for (e, material) in &mut mesh_materials { + if !parents.iter_ancestors(e).any(|p| p == entity) { + continue; + } + if let Some(mut material) = material { + if material.0 != handle { + material.0 = handle.clone(); + } + } else { + commands + .entity(e) + .remove::>() + .insert(MeshMaterial3d(handle.clone())); + } + } + } + let weights: Vec = parts + .library + .morphs + .iter() + .enumerate() + .map(|(i, n)| { + state.draft["morphs"][n] + .as_f64() + .unwrap_or(if (2..19).contains(&i) { 0.25 } else { 0. }) + .clamp(0., 0.5) as f32 + }) + .collect(); + for (entity, mut morph) in &mut morphs { + if parents + .iter_ancestors(entity) + .any(|p| parts.instances.values().any(|e| *e == p)) + && morph.weights().len() == weights.len() + && morph.weights() != weights + { + morph.weights_mut().copy_from_slice(&weights); + } + } + if rebind { + *animation = default(); + } + if parts.applied != preview { + parts.applied = preview; + state.status.clear(); + state.redraw = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn owned_library_choices_resolve_without_conversion() { + let Ok(path) = std::env::var("SKATE_CAC_TEST_LIBRARY") else { + return; + }; + let library: Library = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let parts = Parts { + library, + ..default() + }; + let mut failures = vec![]; + for gender in ["male", "female"] { + let base = &parts.library.defaults[gender]; + assert!( + parts.resolve(base).is_ok(), + "{gender}: {:?}", + parts.resolve(base) + ); + for (id, part) in &parts.library.models { + if !["", gender, "unisex"].contains(&part.flag("Gender")) { + continue; + } + if ["Arm", "Leg", "Organ", "Rostral", "InnerTorso"].contains(&part.slot.as_str()) { + continue; + } + for mid in &part.materials { + let mut p = base.clone(); + p["selections"][&part.slot] = + serde_json::json!({"asset_id":id,"material_id":mid}); + if let Err(e) = parts.resolve(&p) { + failures.push(format!("{gender} {} {}: {e}", part.slot, part.name)); + } + } + } + for tone in ["light", "dark"] { + let mut p = base.clone(); + p["skin"] = serde_json::json!(tone); + if let Err(e) = parts.resolve(&p) { + failures.push(format!("{gender} {tone}: {e}")); + } + } + } + failures.sort(); + failures.dedup(); + assert!(failures.is_empty(), "{}", failures.join("\n")); + } +} diff --git a/crates/skate-game/src/customiser_prepass.wgsl b/crates/skate-game/src/customiser_prepass.wgsl new file mode 100644 index 00000000..e6227c7f --- /dev/null +++ b/crates/skate-game/src/customiser_prepass.wgsl @@ -0,0 +1,172 @@ +// Bevy 0.18.1 pbr_prepass.wgsl, MIT/Apache-2.0, copyright Bevy contributors. +// Match native hair coverage in colour, depth and shadow passes. +struct SkinStamp { + transform: vec4, + rectangle: vec4, + enabled: vec4, +} +@group(#{MATERIAL_BIND_GROUP}) @binding(103) var hair_opacity: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(102) var stamp: SkinStamp; +#import bevy_pbr::{ + pbr_prepass_functions, + pbr_bindings, + pbr_bindings::material, + pbr_types, + pbr_functions, + pbr_functions::SampleBias, + prepass_io, + mesh_bindings::mesh, + mesh_view_bindings::view, +} + +#import bevy_render::bindless::{bindless_samplers_filtering, bindless_textures_2d} + +#ifdef MESHLET_MESH_MATERIAL_PASS +#import bevy_pbr::meshlet_visibility_buffer_resolve::resolve_vertex_output +#endif + +#ifdef BINDLESS +#import bevy_pbr::pbr_bindings::material_indices +#endif // BINDLESS + +fn customiser_alpha_discard(in: prepass_io::VertexOutput) { +#ifdef VERTEX_UVS_B + let alpha = textureSample(hair_opacity, hair_sampler, in.uv_b).r; + if stamp.enabled.y > 0.5 { + let coverage = pbr_functions::alpha_discard(pbr_bindings::material, vec4(1.0, 1.0, 1.0, alpha)); + return; + } +#endif + pbr_prepass_functions::prepass_alpha_discard(in); +} + +#ifdef PREPASS_FRAGMENT +@fragment +fn fragment( +#ifdef MESHLET_MESH_MATERIAL_PASS + @builtin(position) frag_coord: vec4, +#else + in: prepass_io::VertexOutput, + @builtin(front_facing) is_front: bool, +#endif +) -> prepass_io::FragmentOutput { +#ifdef MESHLET_MESH_MATERIAL_PASS + let in = resolve_vertex_output(frag_coord); + let is_front = true; +#else // MESHLET_MESH_MATERIAL_PASS + +#ifdef BINDLESS + let slot = mesh[in.instance_index].material_and_lightmap_bind_group_slot & 0xffffu; + let flags = pbr_bindings::material_array[material_indices[slot].material].flags; + let uv_transform = pbr_bindings::material_array[material_indices[slot].material].uv_transform; +#else // BINDLESS + let flags = pbr_bindings::material.flags; + let uv_transform = pbr_bindings::material.uv_transform; +#endif // BINDLESS + + // If we're in the crossfade section of a visibility range, conditionally + // discard the fragment according to the visibility pattern. +#ifdef VISIBILITY_RANGE_DITHER + pbr_functions::visibility_range_dither(in.position, in.visibility_range_dither); +#endif // VISIBILITY_RANGE_DITHER + + customiser_alpha_discard(in); +#endif // MESHLET_MESH_MATERIAL_PASS + + var out: prepass_io::FragmentOutput; + +#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION + out.frag_depth = in.unclipped_depth; +#endif // UNCLIPPED_DEPTH_ORTHO_EMULATION + +#ifdef NORMAL_PREPASS + // NOTE: Unlit bit not set means == 0 is true, so the true case is if lit + if (flags & pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + let double_sided = (flags & pbr_types::STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT) != 0u; + + let world_normal = pbr_functions::prepare_world_normal( + in.world_normal, + double_sided, + is_front, + ); + + var normal = world_normal; + +#ifdef VERTEX_UVS +#ifdef VERTEX_TANGENTS +#ifdef STANDARD_MATERIAL_NORMAL_MAP + +// TODO: Transforming UVs mean we need to apply derivative chain rule for meshlet mesh material pass +#ifdef STANDARD_MATERIAL_NORMAL_MAP_UV_B + let uv = (uv_transform * vec3(in.uv_b, 1.0)).xy; +#else + let uv = (uv_transform * vec3(in.uv, 1.0)).xy; +#endif + + // Fill in the sample bias so we can sample from textures. + var bias: SampleBias; +#ifdef MESHLET_MESH_MATERIAL_PASS + bias.ddx_uv = in.ddx_uv; + bias.ddy_uv = in.ddy_uv; +#else // MESHLET_MESH_MATERIAL_PASS + bias.mip_bias = view.mip_bias; +#endif // MESHLET_MESH_MATERIAL_PASS + + let Nt = +#ifdef MESHLET_MESH_MATERIAL_PASS + textureSampleGrad( +#else // MESHLET_MESH_MATERIAL_PASS + textureSampleBias( +#endif // MESHLET_MESH_MATERIAL_PASS +#ifdef BINDLESS + bindless_textures_2d[material_indices[slot].normal_map_texture], + bindless_samplers_filtering[material_indices[slot].normal_map_sampler], +#else // BINDLESS + pbr_bindings::normal_map_texture, + pbr_bindings::normal_map_sampler, +#endif // BINDLESS + uv, +#ifdef MESHLET_MESH_MATERIAL_PASS + bias.ddx_uv, + bias.ddy_uv, +#else // MESHLET_MESH_MATERIAL_PASS + bias.mip_bias, +#endif // MESHLET_MESH_MATERIAL_PASS + ).rgb; + let TBN = pbr_functions::calculate_tbn_mikktspace(normal, in.world_tangent); + + normal = pbr_functions::apply_normal_mapping( + flags, + TBN, + double_sided, + is_front, + Nt, + ); + +#endif // STANDARD_MATERIAL_NORMAL_MAP +#endif // VERTEX_TANGENTS +#endif // VERTEX_UVS + + out.normal = vec4(normal * 0.5 + vec3(0.5), 1.0); + } else { + out.normal = vec4(in.world_normal * 0.5 + vec3(0.5), 1.0); + } +#endif // NORMAL_PREPASS + +#ifdef MOTION_VECTOR_PREPASS +#ifdef MESHLET_MESH_MATERIAL_PASS + out.motion_vector = in.motion_vector; +#else + out.motion_vector = pbr_prepass_functions::calculate_motion_vector(in.world_position, in.previous_world_position); +#endif +#endif + + return out; +} +#else +@fragment +fn fragment(in: prepass_io::VertexOutput) { + customiser_alpha_discard(in); +} +#endif // PREPASS_FRAGMENT diff --git a/crates/skate-game/src/customiser_stamp.wgsl b/crates/skate-game/src/customiser_stamp.wgsl new file mode 100644 index 00000000..8801336f --- /dev/null +++ b/crates/skate-game/src/customiser_stamp.wgsl @@ -0,0 +1,135 @@ +// StandardMaterial entry point; the added block only composes skin-stamp albedo. +// Bevy 0.18.1 pbr.wgsl, MIT/Apache-2.0, copyright Bevy contributors. +struct SkinStamp { + transform: vec4, + rectangle: vec4, + enabled: vec4, +} +@group(#{MATERIAL_BIND_GROUP}) @binding(100) var stamp_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(101) var stamp_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(103) var hair_opacity: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(102) var stamp: SkinStamp; +#import bevy_pbr::{ + pbr_types, + pbr_functions::alpha_discard, + pbr_fragment::pbr_input_from_standard_material, + decal::clustered::apply_decals, +} + +#ifdef PREPASS_PIPELINE +#import bevy_pbr::{ + prepass_io::{VertexOutput, FragmentOutput}, + pbr_deferred_functions::deferred_output, +} +#else +#import bevy_pbr::{ + forward_io::{VertexOutput, FragmentOutput}, + pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, + pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT, +} +#endif + +#ifdef VISIBILITY_RANGE_DITHER +#import bevy_pbr::pbr_functions::visibility_range_dither; +#endif + +#ifdef MESHLET_MESH_MATERIAL_PASS +#import bevy_pbr::meshlet_visibility_buffer_resolve::resolve_vertex_output +#endif + +#ifdef OIT_ENABLED +#import bevy_core_pipeline::oit::oit_draw +#endif // OIT_ENABLED + +#ifdef FORWARD_DECAL +#import bevy_pbr::decal::forward::get_forward_decal_info +#endif + +@fragment +fn fragment( +#ifdef MESHLET_MESH_MATERIAL_PASS + @builtin(position) frag_coord: vec4, +#else + vertex_output: VertexOutput, + @builtin(front_facing) is_front: bool, +#endif +) -> FragmentOutput { +#ifdef MESHLET_MESH_MATERIAL_PASS + let vertex_output = resolve_vertex_output(frag_coord); + let is_front = true; +#endif + + var in = vertex_output; + + // If we're in the crossfade section of a visibility range, conditionally + // discard the fragment according to the visibility pattern. +#ifdef VISIBILITY_RANGE_DITHER + visibility_range_dither(in.position, in.visibility_range_dither); +#endif + +#ifdef FORWARD_DECAL + let forward_decal_info = get_forward_decal_info(in); + in.world_position = forward_decal_info.world_position; + in.uv = forward_decal_info.uv; +#endif + + // generate a PbrInput struct from the StandardMaterial bindings + var pbr_input = pbr_input_from_standard_material(in, is_front); + + +#ifdef VERTEX_UVS_B + // The diffuse alpha is NOT coverage. Hair has a separate red-channel + // opacity map on secondary UVs (cac_hair_defaultVS/PS). + let hair_alpha = textureSample(hair_opacity, hair_sampler, in.uv_b).r; + pbr_input.material.base_color.a = mix(pbr_input.material.base_color.a, hair_alpha, stamp.enabled.y); + // cacstamp_skin_defaultPS: skin * (1 + (decal.rgb - 1) * decal.a). + // Its VS transforms TEXCOORD1 by the two i_customGraphic rows. + let stamp_uv = in.uv_b * stamp.transform.xy + stamp.transform.zw; + let decal = textureSample(stamp_texture, stamp_sampler, stamp_uv); + let inside = all(in.uv_b >= stamp.rectangle.xy) && all(in.uv_b <= stamp.rectangle.zw); + let opacity = decal.a * stamp.enabled.x * select(0.0, 1.0, inside); + pbr_input.material.base_color = vec4( + pbr_input.material.base_color.rgb * mix(vec3(1.0), decal.rgb, opacity), + pbr_input.material.base_color.a); +#endif + + // alpha discard + pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color); + + // clustered decals + apply_decals(&pbr_input); + +#ifdef PREPASS_PIPELINE + // write the gbuffer, lighting pass id, and optionally normal and motion_vector textures + let out = deferred_output(in, pbr_input); +#else + // in forward mode, we calculate the lit color immediately, and then apply some post-lighting effects here. + // in deferred mode the lit color and these effects will be calculated in the deferred lighting shader + var out: FragmentOutput; + if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + out.color = apply_pbr_lighting(pbr_input); + } else { + out.color = pbr_input.material.base_color; + } + + // apply in-shader post processing (fog, alpha-premultiply, and also tonemapping, debanding if the camera is non-hdr) + // note this does not include fullscreen postprocessing effects like bloom. + out.color = main_pass_post_lighting_processing(pbr_input, out.color); +#endif + +#ifdef OIT_ENABLED + let alpha_mode = pbr_input.material.flags & pbr_types::STANDARD_MATERIAL_FLAGS_ALPHA_MODE_RESERVED_BITS; + if alpha_mode != pbr_types::STANDARD_MATERIAL_FLAGS_ALPHA_MODE_OPAQUE { + // The fragments will only be drawn during the oit resolve pass. + oit_draw(in.position, out.color); + discard; + } +#endif // OIT_ENABLED + +#ifdef FORWARD_DECAL + out.color.a = min(forward_decal_info.alpha, out.color.a); +#endif + + return out; +} diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index ab9356cc..14f0089b 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, @@ -101,7 +101,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)] @@ -209,7 +209,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))); @@ -247,9 +247,11 @@ 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( mut config: ResMut, mut transition: ResMut, + mut customiser: ResMut, + nav: Res, mut physics: ResMut, keys: Res>, mut menu: ResMut, @@ -262,21 +264,22 @@ fn interact( time.pause(); return; } - 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 { @@ -325,6 +328,7 @@ fn interact( 9 => { exit.write(AppExit::Success); } + 10 => customiser.begin(), _ => {} } if row < 5 { @@ -399,13 +403,14 @@ fn labels( menu: Res, transition: Res, time: 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 @@ -443,7 +448,8 @@ fn labels( 6 => format!("Map {}", menu.maps[menu.selected_map].label), 7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() }, 8 => "Resume".into(), - _ => "Quit game".into(), + 9 => "Quit game".into(), + _ => "Character customiser".into(), }; } ***status = if transition.busy() { 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 d1f7eec5..50ce415b 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -18,6 +18,9 @@ mod skater_animation; mod verification; mod performance; mod graphics_menu; +mod customiser; +mod customiser_parts; +mod customiser_material; 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 12844ce3..63678038 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -170,6 +170,15 @@ mod exchange_tests { } impl GamePhysics { + pub(crate) fn set_gesture_preferences(&mut self, gestures: Option<[u32; 4]>) { + self.animation_profile.gesture_selections = gestures.filter(|g| g.iter().all(|v| *v < 37)); + } + 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/crates/skate-game/src/physics/ground_runtime/settings.rs b/crates/skate-game/src/physics/ground_runtime/settings.rs index f17bd963..a3965f07 100644 --- a/crates/skate-game/src/physics/ground_runtime/settings.rs +++ b/crates/skate-game/src/physics/ground_runtime/settings.rs @@ -293,3 +293,21 @@ mod tests { super::super::GroundState::load(&data, "default", true).unwrap(); } } + +#[cfg(test)] +#[test] +#[ignore = "requires extracted private stock collections"] +fn customiser_truck_tightness_changes_authored_steering() { + use skate_core::riding::steering::{SteeringInput, calculate_tilt}; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let data = Collections::load(&root).unwrap(); + let settings = GroundSettings::load(&data, "default", "default").unwrap(); + let samples: Vec<_> = [0.0, 0.7, 1.0].into_iter().map(|tightness| calculate_tilt( + &settings.steering, + SteeringInput { turn: 0.6, absolute_body_speed: 5.0, flipped_controls_scalar: 1.0, + truck_tightness: tightness, ..Default::default() }, None, None, + )).collect(); + assert!(samples[0].abs() > samples[2].abs()); + assert!((samples[2] / samples[0] - settings.steering.tight_trucks_scalar).abs() < 0.00001); + eprintln!("Authored truck tightness tilt samples: {samples:?}; tight scalar {}", settings.steering.tight_trucks_scalar); +} diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index b6263ced..f7bae298 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -97,6 +97,18 @@ impl SkaterAnimation { self.request_checkpoint_stance(u32::from(forward)); } + /// Native Initialize82B97E38 selects both orientation/mirror bits for + /// regular stance. A profile edit changes the natural basis while retaining + /// the current relative stance and trick state. + pub(crate) fn set_customisation(&mut self, natural: u32, style: u32) { + if natural <= 1 && self.state.publication.natural_stance != natural as i32 { + self.state.publication.natural_stance = natural as i32; + self.state.flags ^= 0xC000_0000; + } + // GetCACSettings82590D20..D84: 0=null,1=Loose,2=Gonzo,3=Aggressive. + let name: &[u8] = match style { 1 => b"Loose", 2 => b"Gonzo", 3 => b"Aggressive", _ => b"" }; + self.motion.playback_context.pro_skater = encode(name); + } pub fn stance(&self) -> (bool, bool) { (self.state.fakie(), self.state.mirrored()) } diff --git a/crates/skate-game/src/tests/physics_startup.rs b/crates/skate-game/src/tests/physics_startup.rs index 83097702..91e4954d 100644 --- a/crates/skate-game/src/tests/physics_startup.rs +++ b/crates/skate-game/src/tests/physics_startup.rs @@ -356,7 +356,7 @@ fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() #[test] #[ignore = "requires private stock skater, animation banks and collections"] -fn stock_wheel_hardness_reaches_ground_force_and_torque() { +fn customiser_equipment_reaches_ground_force_and_torque() { let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); let root = std::path::Path::new(&root); let manifest = skate_data::GameAssets::load(root).unwrap(); @@ -365,6 +365,10 @@ fn stock_wheel_hardness_reaches_ground_force_and_torque() { for hardness in [0.0_f32, 0.7, 1.0] { let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + crate::customiser::apply_preferences( + &serde_json::json!({"truck": hardness, "wheel": hardness}), + &mut physics, &mut skater.animation, + ); let mut controls = PlayerControls::default(); let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); let input = crate::input::ControllerInput::default(); @@ -382,7 +386,8 @@ fn stock_wheel_hardness_reaches_ground_force_and_torque() { // Replay identical observed sideways travel at the Ground input boundary. // Only the published profile input changes; run the actual Ground adapter. let p = &mut skater.player_input.processed; - p.scalar_2764 = hardness; + assert_eq!(p.scalar_2764, hardness); + assert_eq!(p.truck_tightness_2760, hardness); p.vectors_400_416 = [[1.0_f32, 0.0, 3.0, 0.0].map(f32::to_bits); 2]; p.scalar_2656 = 10.0_f32.sqrt(); skater.ground.state.elapsed_2648 = 0.25; @@ -454,3 +459,48 @@ fn stock_geometry_loads_and_reaches_the_live_board_solver() { let deck = physics.board.part_transforms()[BodyId::Deck.index()]; assert!(deck.translation.y > ground::HEIGHT); } + +#[test] +#[ignore = "requires private stock animation banks and collections; no window or renderer"] +fn customiser_styles_and_postures_change_stock_pose() { + use skate_core::animation::playback::{PlayAnimation, PlayAnimationInstance, TransitionSettings}; + use skate_core::animation::playback_tree::{Evaluation, PoseCommand}; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + 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 skater = SkaterRuntime::load(&root, &graphs, &physics, "normal").unwrap(); + let operation = PlayAnimation { + animation: "S_R_PUSHLSP_HSTR_N_0_CYC1".into(), switch_animation: None, mirror_animation: None, + no_board_animation: None, playback_speed: 1.0, apply_posture: true, + transition: TransitionSettings { kind: 1, seconds: 0.0, under: 0, matching: 0, use_channels_from_weights: false }, + parameters: vec![], + }; + let mut samples = Vec::new(); + for (style, posture) in [(0,0), (1,0), (2,0), (3,0), (0,1), (0,2), (0,3)] { + crate::customiser::apply_preferences( + &serde_json::json!({"style":style, "posture":posture}), + &mut physics, &mut skater.animation, + ); + let motion = &mut skater.animation.motion; + PlayAnimationInstance::default().begin(&operation, &mut motion.playback_context, &mut motion.animation).unwrap(); + motion.animation.apply_parameters().unwrap(); + motion.animation.seek_current_fraction(0.35); + let commands = motion.animation.evaluate_pose(Evaluation { cull_threshold: 0.0001, update_history: false }).unwrap(); + let pose = skater.animation.evaluator.evaluate(&commands).unwrap(); + let names: Vec<_> = commands.iter().filter_map(|c| match c { + PoseCommand::Clip { name, .. } | PoseCommand::Pose { name } => Some(name.clone()), _ => None, + }).collect(); + eprintln!("Style {style}, posture {posture}: {names:?}"); + if let Some(baseline) = samples.first() { + assert!(&pose != baseline, "Style {style}, posture {posture} did not change the evaluated stock pose"); + } + samples.push(pose); + } + skater.animation.set_customisation(1,0); + let stance = skater.animation.stance(); + skater.animation.set_customisation(0,0); + assert_ne!(skater.animation.stance(), stance); + skater.animation.set_customisation(1,0); + assert_eq!(skater.animation.stance(), stance); +} diff --git a/docs/character-customisation.md b/docs/character-customisation.md new file mode 100644 index 00000000..20c1c2b1 --- /dev/null +++ b/docs/character-customisation.md @@ -0,0 +1,223 @@ +# Character customiser V4 + +The dedicated local test build uses `Play Character Customiser V4.bat` and +`skate3rust-customiser-v4.exe`. Open Escape/Start, then Character customiser. +The game and launcher have not been executed for validation; the build is ready +for user playtesting. No existing game executable is replaced. + +## Hair coverage and gameplay verification (V3) + +Hair now uses its independent opacity texture's **red channel on TEXCOORD1**. +The owned `cac_hair_defaultVS` copies primary/secondary UVs into XY/ZW, and +`cac_hair_defaultPS` samples diffuse RGB from XY and opacity R from ZW. Output +alpha is opacity R times `i_params.x`. The old combined diffuse-alpha texture +was incorrect: on the moussed-up example only 0.29% of diffuse pixels exceed the +0.5 cutoff, versus 42.18% of the opacity red channel. That discarded most raised +hair geometry. All 31 opacity-bearing hair materials now retain separate maps. +Thin hair cards render from both sides. The colour and depth/shadow passes use +the same corrected coverage; existing PBR lighting formulas remain unchanged. + +V3 uses `library-v3.json` when present, with a legacy `library.json` fallback. +For side-by-side preparation set `"library_index":"library-v3.json"` in the +private preparation config. V2's index, executable and launcher are retained. + +Offline checks use the same `apply_preferences` function called by the menu, +then the real stock actor publication, processed physics input and consumers: + +- Truck tightness reaches processed field 2760. At identical steering input, + loose/default/tight produce tilt 0.08955247 / 0.07074646 / 0.062686734. The + tight/loose ratio is the loaded native `tight_trucks_scalar`, 0.7. +- Wheel hardness reaches processed field 2764. At identical sideways travel, + soft/default/hard produce side force -23.31247 / -15.153105 / -11.656235. + Ratios match `physicswheels/default/SoftestWheelSideFrictionScalar` from the + extracted collection data. Straightening torque also changes, from -1.113991 + to 1.1697853 at the endpoints. These are controlled test samples, not universal + steering angles, grip percentages or speed ratings. +- Standard/Loose/Gonzo/Aggressive select distinct stock push clips through the + `PROSKATER` selector: default, Hsu, Gonzales and PJ respectively. Evaluated bone + poses differ. Stiff/Slouch/Buff add their respective authored posture poses + and change the evaluated result. Switching natural stance changes its basis + and switching back restores it. + +The stock riding idle is shared between styles. Style differences appear in +specific authored movements (including pushes and selected tricks), and posture +is applied on eligible motion-tree construction. Wheel/truck merchandise models +are appearance choices; **Wheel hardness** and **Truck tightness** are the native +handling controls. No invented brand-dependent statistics are added. + +Shader validation covers colour, depth/shadow and normal prepass paths, each +with and without secondary UVs. Tests run without a game window or GPU. The hair +fix still needs the user's visual playtest; no game process was launched here. + +## Menu and controls + +Right stick left/right rotates the character preview through 360 degrees. A +24% dead zone prevents stick drift; rotation accelerates with stick deflection +up to 2 radians/second. It uses real frame time while gameplay is paused and +keeps the existing automatic close-up framing. The viewing angle resets when +opening the customiser and is not saved into the character profile. V4 retains +the V3 hair and gameplay changes and uses the same prepared library. + +Four main sections: **Body, Clothes, Board, Style**. Item lists are alphabetical, +fit the available panel height, and can be filtered by typing. Backspace edits a +search; Escape/B clears it before going back. Arrows/D-pad/left stick browse, +left/right or visible minus/plus buttons adjust, and Enter/A chooses. Mouse wheel +and page buttons move through lists. Back returns one level. **Done**, present +only at the root, writes the profile and resumes skating. There are no repeated +Save buttons or material submenus. + +Names remove exporter terms and distinguish new/worn finishes. Board graphics, +trucks and wheels are individually named entries instead of hundreds of hidden +variants behind one model. The camera frames the selected area, and turns around +for back tattoos. Tattoo previews temporarily use the authored viewing outfit; +this does not change the saved clothes. + +## Implemented controls + +- Both bodies use their authored fallback outfits and retail rigs. +- Seven native skin colours, native hair colours, hairstyles and facial hair. +- Body weight/definition and all 17 individual facial controls, with native + `0..0.5` ranges and unchanged defaults. +- Ten native face presets per body, decoded by target hash and normalized range. +- Upper/lower tattoos: 170 owned designs; left/right limbs plus chest/back + placement for upper-body stamps. Tattoos can be removed and are saved. +- Hats, T-shirts, shirts, hoodies, jackets, sweaters, pants/shorts, shoes, socks, + accessories, decks, trucks and wheels. Authored variants and primary clothing + colours are selectable. Hat/hair, sleeves, inner tops, legs/socks and accessory + removal dependencies are resolved together. +- All 37 gestures in each of the four direction slots, regular/goofy stance, + Standard/Loose/Gonzo/Aggressive skating styles, and native posture options. +- Truck tightness and wheel hardness retain `0..1`, step `0.1`, default `0.7`. + +Physical/animation preferences apply to the running actor. Posture and skating +style are consumed by the appropriate native motion construction when skating +resumes; selecting them does not fabricate a separate preview animation. + +## Immediate appearance updates + +The Python converter is no longer invoked by the game. Preparation creates 480 +resident part GLBs and shared textures. Visible choices also prefetch their +resolved sleeve, leg, hair and skin dependencies. Selection changes visibility +and material handles as one transaction after dependencies are ready. Morph +weights update directly on the GPU. Existing parts stay visible during initial +asset loading. First-time GPU/texture loading can still take frames; no measured +zero-latency or frame-rate claim is made without gameplay testing. + +Each visible rig is bound to the existing stock pose. Shared joint entities are +bound once. The untouched original character remains in use until the customiser +is opened or a valid saved profile is found. The profile lives at +`settings/character.json` beside the test assets and is written through a temporary +file and rename. Legacy profiles without a gender field are interpreted as male. +Map restarts reuse the same asset/settings root and reload the saved profile. + +## Asset preparation and validation + +Retail payloads, catalogues, converted parts and machine paths remain private. +The source archive and installed stock assets are reused; no downloads, Blender, +ISO prompts, or map reconversion are needed. The non-geometric Misc model is the +stamp catalogue, not a missing renderable part. The female face's extra XML +material group is retained in metadata; its high-LOD source has one drawable mesh. + +Preparation commands, using a private configuration with the existing source and +asset roots: + +```text +python -m tools.asset_pipeline.customisation_library +python -m tools.asset_pipeline.customisation_profiles +``` + +The old worker remains an offline compatibility tool. It is not the runtime. + +Validation: 21 Python tests and eight Rust customiser tests pass. Owned-data checks +cover all 480 GLBs, indices, skin weights, 22 GPU target slots, texture references, +both default outfits, all selectable model/material combinations against those +outfits, presets, search, colour selection, JSON round trips, and temporary tattoo +outfits. Shader composition is parsed and validated offline with Bevy's shader +cache, with and without secondary UVs; this creates no window or GPU device. +Release compilation also passes. Interactive layout, controller feel and visual +appearance still require the user's game test. + +This is a sandbox catalogue, with owned items available irrespective of career +unlock state. The existing StandardMaterial lighting remains in use. Full retail +lighting/multipass material parity is outside this UI/profile update; primary +clothing tint is not a claim of every native colour-zone effect. + +## Additional native evidence for V2 + +- `GetCACSettings`, `0x82590B50`, including its complete tail: regular/goofy, + four gesture indices, posture, and style selectors Standard/Loose/Gonzo/Aggressive. +- `ResetGestureSet`, `0x824FA730`: marks all 37 gesture entries available and + initializes the first four in table order. +- `InitializeSkaterAnim`, `0x82B97E38`: stance basis and high flag bits. Changing + natural stance preserves the actor's relative stance. +- `cac_presets`: 32-byte MorphPreset records, target hash at byte 8, normalized + float at byte 24; invert `0x8253CD18` normalization using native target ranges. +- `cacstamp_skin_defaultVS`: transforms TEXCOORD1 with the two i_customGraphic + rows. `cacstamp_skin_defaultPS`: multiplies skin albedo by + `1 + (decal.rgb - 1) * decal.a`, before lighting. The V2 extension adds this + composition and retains Bevy's existing PBR/post-lighting entry point. +- RX2 format `0x002C23A5` is Xenos format 37, FLOAT2, not SHORT4N. The secondary + UV decoder now reads two big-endian float32 values. Retail rectangle order is + top/bottom/left/right; conversion to downward texture V is corroborated by + artwork pixel bounds and exposed-limb UV regions. Left limb positions also + agree with the authored LeftArm bind positions. + +## Native evidence + +Addresses below are virtual addresses in the inspected image whose dump base +is `0x82000000`; subtract that base for a dump-relative offset. Symbol names +from the prior Ghidra export are leads, corroborated with instructions and +authored data. The original IDA database was opened read-only; symbol import +and exploratory disassembly used a private copy. + +Source identities: + +- Owned `default.xex` SHA-256: + `1db39496585c521d17a2137804f42cf73ebed2b32cac166ec42dbf772f4dcf7f`. +- TU3 raw image SHA-256: + `f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`. +- CAC XML SHA-256: + `be2f23ab6d2f29b5ac5e143133788e72e1bebd073adedb343effd1fd7e94f4f1`. +- `skatercollections.vlt` SHA-256: + `3b7dbd062bb1c906a085514355afff35cfa22f486ae70820c5ad1a42a7aab25b`. +- The character archive identity is recorded in the existing fallback manifest. + +Observed native behaviors: + +| Consumer | Evidence and implication | +| --- | --- | +| `FrontEndState_CaC::ReceiveInput`, `0x825FC7E8` | Cases 47/48 adjust recipe fields `+0x2004/+0x2008` by signed `0.1`, clamped to `[0,1]`. Constants: `0x820641A8=0.1`, `0x82165A10=0`, `0x8231A844=1`, `0x8216DEE0=-1`. These are truck tightness/wheel hardness preferences, distinct from merchandise IDs. | +| `GetIntegerValue`, `0x825FFB18` | Cases 47/48 read those same floats and convert to display ticks with the scale at `0x821963E4=10` and rounding offset `0x8209975C=0.5`. | +| `CASMorphParams` initialization, `0x8253CA60` | Uses the `cac_morph_params` layout's target order at `+4`, target prefix at `+0`, and parameter triples at `+12/+16/+20` or `+24/+28/+32`. Each target's working record is 28 bytes. | +| Morph normalization, `0x8253CD18` | Clamps to that target's minimum/maximum then computes `(value-min)/(max-min)`. The UI's normalized value is not the mesh's direct delta weight. | +| `GetTargetOrder`, `0x8253CF50` | Matches target-name prefixes against 19 entries; clothing `fat_*`/`thin_*` must follow the corresponding native mapping. | +| `CASPlayerInitializer::InitializePlayer`, `0x8253D470` | Copies each working record's default at `+8` into the 19-value customisation container; chooses default parts through `CACPartDB`. | +| `CASAssembler::Correct`, `0x8253F648` | Coordinates hair, rostral, torso, organ, legs/socks, other accessories, removals, colours and stamp information. XML flags are inputs to this sequence, not sufficient proof of a replacement assembly algorithm. | +| `CorrectRemovals`, `0x8253FD40` | Walks the native slots, resolves component names in comma-separated removal strings, and clears the referenced slots. | +| `CorrectRostral`, `0x825406E0`; `CorrectTorso`, `0x82540828` | Further select material/part combinations from native colour and clothing relationships. | +| `GetCACSettings`, `0x82590B50` | Reads saved stance, four gestures and posture-related profile fields. Existing Rust posture consumers already preserve the pending-tree application semantics. | + +Observed `cac_morph_params` values in this bank are `min=0`, `max=0.5` for +all 19 targets. Face defaults are `0.25`; fat and thin defaults are zero. +Target order is **fat=0, thin=1**. The existing fallback parser's descriptive +`skinniness/fatness` names must not be used to infer that ordering merely +because both default values happen to be zero. Nose height/length have swapped +target-order and UI-zone indices; the export retains both. + +Do not equate low-level constructors with gameplay defaults. +`RecipeData` constructor `0x82DDE700` initializes equipment values to zero, +while the current Rust actor profile uses `0.7`, following its documented +actor settings path. That current behavior is unchanged here. Saved/default +CAC profile initialization must be traced before changing it. + +## Integration with map switching and checkpoint recovery + +The customiser plugin coexists with the in-process map menu and session-marker +HUD. Controller navigation is sampled before menu input. Profile preferences +are applied after map-transition publication and before gameplay input, so a +fresh skater receives the saved stance, style and equipment settings before its +first physical tick. Character entities and the resident library remain alive +across map changes. Native checkpoint stance requests remain deferred through +the teleport graph; profile edits do not replace that recovery path. + +The combined release compiles. Runtime/visual checks remain user-run. diff --git a/tools/asset_pipeline/character_glb.py b/tools/asset_pipeline/character_glb.py index f6c58937..d1943350 100644 --- a/tools/asset_pipeline/character_glb.py +++ b/tools/asset_pipeline/character_glb.py @@ -32,9 +32,12 @@ 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, 'roughnessFactor':0.48 if slot in {'SkateTruck','SkateWheel'} else 0.72} + if not geometry_only:pbr['baseColorTexture']=glb.texture(folder/(slot+'_base_color.png')) rough=folder/(slot+'_roughness.png') - if rough.is_file(): + if not geometry_only and rough.is_file(): channel=Image.open(rough).convert('RGB').getchannel('R') white=Image.new('L',channel.size,255);packed=Image.merge('RGB',(white,channel,white));buffer=io.BytesIO();packed.save(buffer,format='PNG') pbr['metallicRoughnessTexture']=glb.texture(buffer.getvalue());pbr['roughnessFactor']=1.0 material={'name':'Retail_'+slot,'pbrMetallicRoughness':pbr,'doubleSided':False, 'alphaMode':component.get('alpha_mode','OPAQUE')} if material['alphaMode']=='MASK':material['alphaCutoff']=component.get('alpha_cutoff',0.5) - if component['textures'].get('normal'):material['normalTexture']=glb.texture(folder/(slot+'_normal.png')) + if not geometry_only and component['textures'].get('normal'):material['normalTexture']=glb.texture(folder/(slot+'_normal.png')) glb.doc['materials'].append(material) attributes={'POSITION':glb.accessor(positions,'VEC3',bounds=True),'NORMAL':glb.accessor(normals,'VEC3'), 'TEXCOORD_0':glb.accessor(raw['uvs'],'VEC2'),'JOINTS_0':glb.accessor(joints,'VEC4',5123), 'WEIGHTS_0':glb.accessor(weights,'VEC4')} + if live_morphs and 'uvs2' in raw: + attributes['TEXCOORD_1']=glb.accessor(raw['uvs2'],'VEC2') primitives.append({'attributes':attributes,'indices':glb.accessor(np.asarray(mesh['tris']).ravel(),'SCALAR',5125), 'material':len(glb.doc['materials'])-1}) + if live_morphs: + names_morph=recipe['morph_assembly'].get('live_targets',['fat','thin']+recipe['morph_assembly']['face_targets']) + zero=glb.accessor(np.zeros_like(positions),'VEC3',bounds=True) + targets={} + for morph in morphs: + name=morph['name'] + key='fat' if name.startswith('fat') else 'thin' if name.startswith('thin') else name + if key not in names_morph:raise ValueError('Unmapped live morph '+name) + targets[key]={'POSITION':glb.accessor(morph['deltas'],'VEC3',bounds=True), + 'NORMAL':glb.accessor(morph['normal_deltas'],'VEC3')} + primitives[-1]['targets']=[targets.get(n,{'POSITION':zero,'NORMAL':zero}) for n in names_morph] mesh_node=len(nodes);nodes.append({'name':'Skate3_SkaterAndBoard','mesh':0,'skin':0}) 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') + if live_morphs: + glb.doc['meshes'][0].update(weights=[.25 if n in recipe['morph_assembly']['face_targets'] else 0. for n in names_morph], + extras={'targetNames':names_morph}) + glb.save(output or private/'skater.glb') if __name__=='__main__': import argparse diff --git a/tools/asset_pipeline/customisation_catalog.py b/tools/asset_pipeline/customisation_catalog.py new file mode 100644 index 00000000..373ac040 --- /dev/null +++ b/tools/asset_pipeline/customisation_catalog.py @@ -0,0 +1,295 @@ +"""Index owned CAC authoring data; extract only explicitly selected resources. + +This is a lossless metadata boundary, not a character assembler or a material +compositor. It deliberately does not equate an authored variant with a usable +in-game option. Gender, unlock, layering, material-loop and stamp rules still +have to be applied by the native CAC implementation. + +Run with ``python -m tools.asset_pipeline.customisation_catalog --help``. +Never distribute its output: the catalog is derived from the user's disc. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import xml.etree.ElementTree as ET + +from tools.owned_game.big import BigArchive + + +CATALOG_PATH = "data/content/recipe/createacharacter/cas_db_xfd1ca62f_000.xml" +MODEL_PREFIX = "data/content/createacharacter/model/cas_db" +TEXTURE_PREFIX = "data/content/createacharacter/texture" + +# Navigation labels requested by the user. This is a scope contract, not a list +# of implemented pages. Do not generate working controls from this list alone. +HIERARCHY = [ + {"name": "Body mods", "children": [ + "Gender", "Skin", "Hair", "Body shape", "Facial presets", + "Face modification", "Facial hair", "Upper body tattoo", "Lower body tattoo", + ]}, + {"name": "Merchandise", "children": [ + "Hats", "Tshirts", "Button shirts", "Hoodies", "Jackets", "Sweaters", + "Pants", "Shoes", "Socks", "Accessories", "Skateboards", + ]}, + {"name": "Edit style", "children": ["Gestures", "Stance", "Style", "Posture"]}, + {"name": "Trucks adjustment", "children": []}, + {"name": "Wheels adjustment", "children": []}, +] + + +def asset_id(value: str) -> str: + if not re.fullmatch(r"(?:0x)?[0-9a-fA-F]{16}", value): + raise ValueError(f"Invalid retail asset ID: {value!r}") + return value.removeprefix("0x").lower() + + +def authored(node: ET.Element) -> dict: + """Keep ordering, attributes, and scalar strings; never invent shader data.""" + result = {"tag": node.tag, "attributes": dict(node.attrib)} + if node.text and node.text.strip(): + result["text"] = node.text + if len(node): + result["children"] = [authored(child) for child in node] + return result + + +def authoring_flags(node: ET.Element) -> dict[str, str]: + result = {} + for group in node.findall("ud"): + if group.get("n") != "cas": + continue + for field in group: + if field.tag in result: + raise ValueError(f"Duplicate CAC field {field.tag}") + result[field.tag] = field.get("value", "") + return result + + +def parse_catalog(xml: bytes, archive_paths: set[str]) -> dict: + root = ET.fromstring(xml) + if (root.tag, root.get("n"), root.get("type"), root.get("schemaversion")) != ( + "compositeasset", "cas_db", "CreateACharacter", "6.0" + ): + raise ValueError("Unsupported CAC authoring schema") + if any(child.tag not in {"mat", "comp"} for child in root): + raise ValueError("Unrecognized CAC root record") + available = {p.lower() for p in archive_paths} + materials = {} + for material in root.findall("mat"): + key = asset_id(material.attrib["id"]) + if key in materials: + raise ValueError(f"Duplicate material {key}") + textures = [] + for parameter in material.findall("sp"): + # Keep duplicate channels and their order rather than silently + # collapsing separate authored bindings into a channel dictionary. + texture = asset_id(parameter.attrib["id"]) + path = f"{TEXTURE_PREFIX}/0x{texture}.rx2" + textures.append({"channel": parameter.attrib["chn"], "id": texture, + "path": path, "in_archive": path.lower() in available}) + materials[key] = { + "shader": material.attrib["type"], "textures": textures, + "flags": authoring_flags(material), "authored": authored(material), + } + components = [] + slots = set() + for component in root.findall("comp"): + slot = component.attrib["n"] + if not re.fullmatch(r"[A-Za-z]+", slot) or slot in slots: + raise ValueError(f"Invalid or duplicate component slot {slot!r}") + slots.add(slot) + if any(child.tag != "mod" for child in component): + raise ValueError(f"Unrecognized record in {slot}") + models = [] + ids = set() + for model in component.findall("mod"): + key = asset_id(model.attrib["id"]) + if key in ids: + raise ValueError(f"Duplicate model {slot}/{key}") + ids.add(key) + lods = [] + lod_indices = set() + for lod in model.findall("lod"): + index = int(lod.attrib["idx"]) + if index < 0 or index in lod_indices: + raise ValueError(f"Invalid or duplicate LOD {slot}/{key}/{index}") + lod_indices.add(index) + # XML arenaid names the RX2 FILE; XML id is the arena asset ID. + # The fallback .recipe stores them in the opposite field order. + model_id = asset_id(lod.attrib["arenaid"]) + path = f"{MODEL_PREFIX}/{slot}/0x{model_id}.rx2" + instances = [] + for instance in lod.findall("matinst"): + variants = [] + variant_ids = set() + for variant in instance.findall("matvar"): + material_id = asset_id(variant.attrib["id"]) + if material_id not in materials: + raise ValueError(f"Missing material definition {material_id}") + if material_id in variant_ids: + raise ValueError(f"Duplicate material variant {slot}/{key}/{material_id}") + variant_ids.add(material_id) + variants.append({"id": material_id, "name": variant.attrib["n"], + "authored": authored(variant)}) + if not variants: + raise ValueError(f"Empty material instance {slot}/{key}") + instances.append(variants) + if not instances: + raise ValueError(f"Missing material instances {slot}/{key}") + lods.append({"index": index, "arena_asset_id": asset_id(lod.attrib["id"]), + "model_id": model_id, "path": path, + "in_archive": path.lower() in available, + "material_instances": instances}) + if not lods: + raise ValueError(f"Missing LODs {slot}/{key}") + models.append({"id": key, "name": model.attrib["n"], + "flags": authoring_flags(model), "lods": lods, + "authored": authored(model)}) + components.append({"slot": slot, "models": models}) + return {"version": 1, "source": {"entry": CATALOG_PATH, + "xml_sha256": hashlib.sha256(xml).hexdigest(), "attributes": dict(root.attrib)}, + "scope": HIERARCHY, "materials": materials, "components": components} + + +def selected_paths(catalog: dict, selections: list[dict]) -> list[str]: + """Validate extraction requests, not gender/outfit/material compatibility. + + Each request supplies slot, asset_id, lod and one material_id per ordered + material instance. Requiring every instance prevents losing female head's + second material loop. Requests do not authorize arbitrary archive paths. + """ + paths = set() + slots = set() + for selection in selections: + slot = selection["slot"] + if slot in slots: + raise ValueError(f"Repeated selected slot {slot}") + slots.add(slot) + candidates = [m for c in catalog["components"] if c["slot"] == slot + for m in c["models"] if m["id"] == asset_id(selection["asset_id"])] + if len(candidates) != 1: + raise ValueError(f"Unknown selected component {slot}/{selection['asset_id']}") + lods = [lod for lod in candidates[0]["lods"] if lod["index"] == selection["lod"]] + if len(lods) != 1: + raise ValueError(f"Unknown selected LOD for {slot}") + lod = lods[0] + if not lod["in_archive"]: + raise ValueError(f"Selected model is absent from archive: {lod['path']}") + choices = selection["material_ids"] + if len(choices) != len(lod["material_instances"]): + raise ValueError(f"{slot} requires {len(lod['material_instances'])} material selections") + paths.add(lod["path"]) + for choice, instance in zip(choices, lod["material_instances"]): + key = asset_id(choice) + if key not in {v["id"] for v in instance}: + raise ValueError(f"Material {key} is not a variant of this {slot} instance") + for texture in catalog["materials"][key]["textures"]: + if not texture["in_archive"]: + raise ValueError(f"Selected texture is absent from archive: {texture['path']}") + paths.add(texture["path"]) + return sorted(paths) + + +def write_private(path: Path, data: bytes) -> str: + """Idempotent, no-clobber staging, including failed or interrupted runs.""" + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + if path.read_bytes() != data: + raise ValueError(f"Refusing to replace different staged content: {path}") + return "reused" + # Publish a complete file without replacing another extractor's output. + # Windows rename is atomic and refuses an existing destination. POSIX + # rename replaces it, so use an exclusive hard link there instead. + descriptor, temporary = tempfile.mkstemp(prefix=".cac-", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + try: + if os.name == "nt": + os.rename(temporary, path) + else: + os.link(temporary, path) + except FileExistsError: + if path.read_bytes() != data: + raise ValueError(f"Conflicting concurrent staged content: {path}") + return "reused" + finally: + Path(temporary).unlink(missing_ok=True) + return "written" + + +def prepare(game_root: Path, output: Path, selections: list[dict] | None = None) -> dict: + game_root = game_root.resolve(strict=True) + output = output.resolve() + if output == game_root or output.is_relative_to(game_root) or game_root.is_relative_to(output): + raise ValueError("The private staging directory must be separate from the owned source") + archive = BigArchive(game_root / "data/content/createacharacter.big") + entries = {e.path.lower(): e for e in archive.entries} + if len(entries) != len(archive.entries): + raise ValueError("Ambiguous case-insensitive paths in CAC archive") + xml = archive.read(entries[CATALOG_PATH.lower()]) + catalog = parse_catalog(xml, set(entries)) + # Resolve every selected dependency before writing any selected resources. + paths = selected_paths(catalog, selections or []) + from tools.asset_pipeline.customisation_native import read_database + native = read_database(game_root, output / "database") + paths.append(CATALOG_PATH) + records = [] + for name in paths: + entry = entries[name.lower()] + data = xml if name == CATALOG_PATH else archive.read(entry) + destination = (output / "source" / name).resolve() + if not destination.is_relative_to(output): + raise ValueError("Staged asset escaped the private directory") + state = write_private(destination, data) + records.append({"path": name, "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), + "state": state}) + models = [m for c in catalog["components"] for m in c["models"]] + report = {"version": 1, "source_xml_sha256": catalog["source"]["xml_sha256"], + "component_slots": len(catalog["components"]), "model_variants": len(models), + "materials": len(catalog["materials"]), + "native_collections": len(native["collections"]), + "native_morphs": len(native["morphs"]), + "multiple_material_instance_lods": sum(len(lod["material_instances"]) > 1 + for m in models for lod in m["lods"]), + "missing_model_paths": sorted({lod["path"] for m in models for lod in m["lods"] + if not lod["in_archive"]}), + "missing_texture_paths": sorted({t["path"] for m in catalog["materials"].values() + for t in m["textures"] if not t["in_archive"]}), + "extracted": records, + "runtime_ready": False, + "requires": ["native CAC assembly and rig validation", "native material and tattoo composition", + "native recipe colour/morph/style defaults and menu filtering"]} + # Catalog output is deterministic. A different disc/version needs its own + # staging directory rather than silently reusing another catalog's files. + write_private(output / "catalog.json", (json.dumps(catalog, indent=2) + "\n").encode()) + write_private(output / "native.json", (json.dumps(native, indent=2) + "\n").encode()) + return report + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--game-root", required=True, type=Path, help="Owned extracted Xbox game directory") + parser.add_argument("--output", required=True, type=Path, help="Private task staging directory, outside game source") + parser.add_argument("--selection", type=Path, help="JSON array of explicit component extraction requests") + args = parser.parse_args() + selections = json.loads(args.selection.read_text(encoding="utf-8")) if args.selection else None + if selections is not None and not isinstance(selections, list): + parser.error("--selection must contain a JSON array") + report = prepare(args.game_root, args.output, selections) + report_path = (args.output / "extraction-report.json").resolve() + if not report_path.is_relative_to(args.output.resolve()): + raise ValueError("Report path escaped the private staging directory") + report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(f"CAC_CATALOG_READY slots={report['component_slots']} models={report['model_variants']} " + f"materials={report['materials']} staged={len(report['extracted'])} runtime_ready=false") + + +if __name__ == "__main__": + main() diff --git a/tools/asset_pipeline/customisation_library.py b/tools/asset_pipeline/customisation_library.py new file mode 100644 index 00000000..922ae7bc --- /dev/null +++ b/tools/asset_pipeline/customisation_library.py @@ -0,0 +1,156 @@ +"""Prepare resident CAC geometry and shared textures outside the game loop.""" +from pathlib import Path +import copy,json,shutil,sys,re,struct +import numpy as np +from PIL import Image,ImageChops +from tools.asset_pipeline.customisation_worker import ROOT,default_profile,flag +from tools.asset_pipeline.customisation_catalog import write_private +from tools.owned_game.big import BigArchive + +def friendly(name): + text=name.lower().replace('_',' ') + replacements=[('shortsleeve','short sleeve'),('longsleeve','long sleeve'),('tshirt','tee'), + ('t-shirt','tee'),('buttonup','button-up'),('zipup','zip-up'),('flippedup','flipped up'), + ('kneedown',''),('thighdown',''),('medhem',''),('lowhem',''),('highhem',''), + ('sleeves pushed up','rolled sleeves'),('sleeves up','rolled sleeves'),('sleevup','rolled sleeves'), + ('colourizable',''),('colorizable',''),('stampable',''),('logoable',''),('material',''), + ('lambert1','original'),('newera','new era'),('45right','angled right'),('45left','angled left'), + ('long sleeve tee','long-sleeve tee'),('short sleeve tee','tee'), + ('short sleeve button-up shirt','short-sleeve shirt'),('long sleeve button-up shirt','long-sleeve shirt'), + ('shirt ls button-up','long-sleeve shirt'),('top button only','top button'), + ('long sleeve mens shirt vneck','long-sleeve v-neck'),('jeans straight leg baggy waistline','baggy straight jeans'), + ('jeans straight leg baggy waistline','baggy straight jeans'),('sunglasses','shades'),('necklaces',''),('tattoo',''),('biglizard','big lizard'), + ('blueflame','blue flame'),('radialsun','sunburst'),('tribalbody','tribal'),('pinstripe','pinstripe ')] + for a,b in replacements:text=text.replace(a,b) + text=re.sub(r'\b(male\d*|female\d*|unisex|mesh|mat|hmat|h|l|high|low|ropa|hem|inner)\b','',text) + text=re.sub(r'\b(?:0x)?[a-f0-9]{8,}\b','',text) + text=re.sub(r'([a-z])([0-9]+)\b',r'\1 \2',text) + text=re.sub(r'\s+',' ',text).strip(' -') + text=text.replace('jeans straight leg baggy waistline','baggy straight jeans').replace('long-sleeve tee rolled sleeves','rolled-sleeve tee') + return text.title() or 'Original' + + +def variant_label(slot, model_name, native_name): + label=friendly(native_name) + worn=bool(re.search(r'(?:_| )d(?:_| |$)|dirty',native_name.lower())) + label=re.sub(r'\b(Diffuse|Tga|D|Malemat|Skateboard|Featured Artist|Instance)\b','',label) + label=re.sub(r'\b0\b','',label) + if slot=='Feet': return 'Worn' if worn else 'New' + model_words=set(friendly(model_name).lower().split()) + words=[w for w in label.split() if w.lower() not in model_words] + label=' '.join(words).strip() + for a,b in [('Ozzie Wright','Ozzie'),('Grass Roots Pullover Hoodie','Grass Roots'), + ('Full Back New Era',''),('Hoodie Hooddown Pullover',''),('Hood Up',''), + ('Hooddown',''),('Closed',''),('Open',''),('In4Mation Colab Zip','In4Mation'), + ('Nightmare Catcher','Nightmare'),('Gonz Animal Bullfish','Bullfish')]: label=label.replace(a,b) + label=re.sub(r'\s+',' ',label).strip() + if not label: label='Original' + return label+(' (Worn)' if worn else '') + + +def prepare(config): + from tools.extract_default_skater import import_rx2_parser,decode_texture,parse_fallback_recipe + 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';out=cache/'library';out.mkdir(exist_ok=True) + catalog=json.loads((cache/'catalog.json').read_text());archive=BigArchive(Path(config['game_root'])/'data/content/createacharacter.big') + entries={e.path.lower():e for e in archive.entries} + def extract(path): + dest=cache/'source'/path + if not dest.exists():write_private(dest,archive.read(entries[path.lower()])) + return dest + parser=import_rx2_parser(ROOT/'tools/vendor/utt');decoded=cache/'decoded';decoded.mkdir(exist_ok=True) + def texture(tid,kind='raw'): + src=decoded/(tid+'.png') + if not src.exists():decode_texture(parser,extract('data/content/createacharacter/texture/0x'+tid+'.rx2'),src) + if kind=='raw':return src.relative_to(assets).as_posix() + dest=out/'textures'/(tid+'_'+kind+'.png');dest.parent.mkdir(exist_ok=True) + if not dest.exists(): + image=Image.open(src).convert('RGBA');a=np.array(image,dtype=np.float64) + if kind=='normal': + x=a[:,:,3]/127.5-1.;y=a[:,:,1]/127.5-1.;scale=np.maximum(np.sqrt(x*x+y*y),1.);x/=scale;y/=scale + z=np.sqrt(np.maximum(0.,1.-x*x-y*y));image=Image.fromarray(np.rint((np.stack((x,y,z),axis=2)*.5+.5)*255.).astype('uint8')) + elif kind=='rough': + grey=ImageChops.invert(image.convert('L'));white=Image.new('L',image.size,255);image=Image.merge('RGB',(white,grey,white)) + image.save(dest) + return dest.relative_to(assets).as_posix() + model_data={};mat_data={};errors=[] + recipe_base=json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text()) + recipe_base['morph_assembly']['live_targets']=['fat','thin']+recipe_base['morph_assembly']['face_targets']+['global_ethnicity_african','global_ethnicity_asian','global_ethnicity_caucasian'] + reference=out/'reference' + for component in recipe_base['components']: + slot=component['slot'];model=next(m for p in catalog['components'] if p['slot']==slot for m in p['models'] if m['id']==component['asset_id']) + src=extract(next(l for l in model['lods'] if l['index']==0)['path']);dest=reference/slot/src.name + dest.parent.mkdir(parents=True,exist_ok=True) + if not dest.exists():shutil.copyfile(src,dest) + defaults={x['material_id']:x for x in recipe_base['components']} + for part in catalog['components']: + for model in part['models']: + if part['slot']=='Misc':continue # Non-geometric stamp catalogue, indexed below. + slot=part['slot'];lod=next(l for l in model['lods'] if l['index']==0);key=model['id'];path=out/(key+'_v4.glb') + try: + if not path.exists(): + source=extract(lod['path']);folder=out/'work'/key/'models'/slot;folder.mkdir(parents=True,exist_ok=True) + mesh_file=folder/source.name + if not mesh_file.exists():shutil.copyfile(source,mesh_file) + parsed=RX2.parse_rx2(str(mesh_file));mesh=next(m for m in parsed['meshes'] if m.get('positions') and m.get('indices')) + morphs=decode_dense_morphs(mesh_file,parsed,len(mesh['positions']),RX2) + recipe=copy.deepcopy(recipe_base);recipe['components']=[dict(slot=slot,textures={},tint=[1.,1.,1.])] + recipe['morph_assembly']['expected_targets']={slot:[m['name'] for m in morphs]} + convert(folder.parent,assets/'private',recipe,output=path,geometry_only=True,live_morphs=True,reference_models=reference) + groups=[[v['id'] for v in group] for group in lod['material_instances']] + model_data[key]=dict(slot=slot,name=friendly(model['name']),flags={k[4:]:v for k,v in model['flags'].items() if v}, + materials=groups[0],groups=groups,scene=path.relative_to(assets).as_posix()) + for v in lod['material_instances'][0]: + mid=v['id'] + if mid in mat_data:continue + mat=catalog['materials'][mid];tex={t['channel']:t['id'] for t in mat['textures']} + if 'diffuse' not in tex:continue + diffuse=texture(tex['diffuse']) + if 'alpha' in tex and slot!='Hair': + alpha_path=assets/texture(tex['alpha']);combined=out/'textures'/(tex['diffuse']+'_'+tex['alpha']+'.png') + if not combined.exists(): + im=Image.open(assets/diffuse).convert('RGBA');alpha=Image.open(alpha_path).convert('RGBA') + alpha=alpha.resize(im.size,Image.Resampling.LANCZOS);mask=alpha.getchannel('A') + if mask.getextrema()==(255,255):mask=alpha.convert('L') + im.putalpha(ImageChops.multiply(im.getchannel('A'),mask));im.save(combined) + diffuse=combined.relative_to(assets).as_posix() + mat_data[mid]=dict(name=variant_label(slot,model['name'],v['name']),flags={k[4:]:val for k,val in mat['flags'].items() if val}, + diffuse=diffuse,normal=texture(tex['normal'],'normal') if 'normal' in tex else None, + rough=texture(tex['specular'],'rough') if 'specular' in tex else None, + opacity=texture(tex['alpha']) if slot=='Hair' and 'alpha' in tex else None, + alpha='alpha' in tex,tint=defaults.get(mid,{}).get('tint', + [0.72,0.57,0.49] if mat['flags'].get('cas.SkinTone')=='light' else + [0.33,0.26,0.23] if mat['flags'].get('cas.SkinTone')=='dark' else + [0.02,0.01,0.01] if slot=='Hair' else [1.,1.,1.]), + metallic=.65 if slot=='SkateTruck' else 0.,roughness=.48 if slot in {'SkateTruck','SkateWheel'} else .72) + except Exception as e:errors.append(dict(model=key,slot=slot,error=str(e))) + print('Prepared',part['slot'],len(model_data),'models',len(mat_data),'materials',flush=True) + tattoos={} + misc=next(p for p in catalog['components'] if p['slot']=='Misc') + for variant in misc['models'][0]['lods'][0]['material_instances'][0]: + mid=variant['id'];mat=catalog['materials'][mid] + if not mat['flags'].get('cas.TattooCategory'):continue + tex={t['channel']:t['id'] for t in mat['textures']} + tattoos[mid]=dict(name=friendly(variant['name']),texture=texture(tex['decal']), + bounds=[float(v) for v in mat['flags']['cas.StampBorderConstraint'].split(',')]) + male=default_profile();male['gender']='male' + female=copy.deepcopy(male);female['gender']='female';female['selections']={} + raw=parse_fallback_recipe(assets/'private/stock/data/cacrecipes/SavedRecipeFallbackFemale.bin',16) + for slot,items in raw['asset_lists'].items(): + if items: + item=items[0];female['selections'][slot]={'asset_id':item['asset_id'],'material_id':item['models'][0]['material_id']} + colours=[] + native=json.loads((cache/'native.json').read_text()) + for row in native['collections']: + if row['class']=='cac_colour' and row['key'].endswith('_clothing') and row['key']!='default_clothing': + colours.append(dict(name=row['key'][:-9].replace('_',' ').title(), + rgb=struct.unpack('>4f',bytes.fromhex(row['fields']['Hash_7827ED970A88B70C']['data']))[:3])) + colours.sort(key=lambda c:c['name']) + library=dict(version=3,colours=colours,tattoos=tattoos,models=model_data,materials=mat_data,defaults={'male':male,'female':female}, + morphs=recipe_base['morph_assembly']['live_targets'],errors=errors) + (cache/config.get('library_index','library.json')).write_text(json.dumps(library,separators=(',',':'))) + print('LIBRARY_READY',len(model_data),len(mat_data),'errors',errors,flush=True) + return library + +if __name__=='__main__':prepare(json.loads(Path(sys.argv[1]).read_text())) diff --git a/tools/asset_pipeline/customisation_native.py b/tools/asset_pipeline/customisation_native.py new file mode 100644 index 00000000..289738dd --- /dev/null +++ b/tools/asset_pipeline/customisation_native.py @@ -0,0 +1,118 @@ +"""Read native CAC settings without changing the gameplay collections schema. + +The XML catalog contains models/materials; BIN/VLT contains preset arrays, +colour parameters, morph order/ranges, cameras and menu filters. Preserve both. +Raw reflected fields remain authoritative; decoded helpers only cover layouts +corroborated with the native code. See docs/character-customisation.md. +""" +from __future__ import annotations + +import hashlib +import math +from pathlib import Path +import struct + +from tools.asset_pipeline.vlt import convert, hash64 +from tools.owned_game.big import BigArchive + + +DATABASE_FILES = { + "skaterschema.bin", "skaterschema.vlt", "skatercollections.bin", + "skatercollections.vlt", "skaterschema_summaryreport.txt", +} + +# Reflected layout offsets are independently corroborated by 8253CA60..CD18. +MORPH_CLASS = 0x7F47CA62F4100F41 +MORPH_FIELDS = { + "target": 0xAAC0F30B119BD14F, # layout +0 + "target_order": 0x0D381DFA82C751E1, # layout +4 + "ui_zone": 0x3D15CA957C745188, # layout +8; not always target_order! + "min_a": 0x0279ED3135F03661, # layout +12 + "max_a": 0xD4340AC527D228E1, # layout +16 + "default_a": 0x49E3727FEB2F6475, # layout +20 + "min_b": 0x11691A8EEF816175, # layout +24 + "max_b": 0x7680C10C0F43EB3E, # layout +28 + "default_b": 0xBE3909745E8A3935, # layout +32 +} + + +def key_hash(key: str) -> int: + return int(key[5:], 16) if key.startswith("Hash_") else hash64(key) + + +def fields_by_hash(row: dict) -> dict[int, dict]: + return {key_hash(key): value for key, value in row["fields"].items()} + + +def float32(field: dict) -> float: + data = bytes.fromhex(field["data"]) + if field["type"] != "EA::Reflection::Float" or len(data) != 4: + raise ValueError("Expected one native Float field") + value, = struct.unpack(">f", data) + if not math.isfinite(value): + raise ValueError("Non-finite native Float field") + return value + + +def decode_morphs(rows: list[dict]) -> list[dict]: + result = [] + for row in rows: + if key_hash(row["class"]) != MORPH_CLASS: + continue + fields = fields_by_hash(row) + values = {name: fields[key] for name, key in MORPH_FIELDS.items()} + order, = struct.unpack(">i", bytes.fromhex(values["target_order"]["data"])) + zone, = struct.unpack(">I", bytes.fromhex(values["ui_zone"]["data"])) + if order == -1: + continue # schema default, not an additional user morph + if not 0 <= order < 19 or not 0 <= zone < 19: + raise ValueError("Native morph index outside the 19-field container") + if values["target"]["type"] != "EA::Reflection::Text": + raise ValueError("Undecoded native morph target name") + entry = {"key": row["key"], "target": values["target"]["data"], + "target_order": order, "ui_zone": zone} + # Retain native branch labels until the gender boolean's meaning is + # verified. The inspected retail bank gives identical A/B ranges. + for branch in ("a", "b"): + bounds = {name: float32(values[f"{name}_{branch}"]) + for name in ("min", "max", "default")} + if not bounds["min"] <= bounds["default"] <= bounds["max"] or bounds["min"] == bounds["max"]: + raise ValueError(f"Invalid native morph range: {row['key']}") + entry[f"branch_{branch}"] = bounds + result.append(entry) + if len(result) != 19 or {m["target_order"] for m in result} != set(range(19)): + raise ValueError("Native morph target order is incomplete or duplicated") + if len({m["target"] for m in result}) != 19: + raise ValueError("Duplicate native morph target name") + return sorted(result, key=lambda m: m["target_order"]) + + +def read_database(game_root: Path, staging: Path) -> dict: + # Import locally to avoid a module cycle with the catalog's optional stage. + from tools.asset_pipeline.customisation_catalog import write_private + + game_root = game_root.resolve(strict=True) + staging = staging.resolve() + if staging == game_root or staging.is_relative_to(game_root) or game_root.is_relative_to(staging): + raise ValueError("Database staging must be separate from owned source") + archive = BigArchive(game_root / "data/big/db.big") + entries = [e for e in archive.entries if e.path.lower().startswith("data/db/") + and Path(e.path).name.lower() in DATABASE_FILES] + if len(entries) != len(DATABASE_FILES): + raise ValueError("Missing or ambiguous owned CAC BIN/VLT inputs") + sources = [] + for entry in entries: + data = archive.read(entry) + name = Path(entry.path).name.lower() + write_private(staging / name, data) + sources.append({"entry": entry.path, "size": len(data), + "sha256": hashlib.sha256(data).hexdigest()}) + # The game ships its class/collection names in this report. No local dump + # or machine-specific reverse-engineering name cache is needed. + names = Path(__file__).with_name("names.txt").read_text(encoding="utf-8").splitlines() + for line in (staging / "skaterschema_summaryreport.txt").read_text(encoding="utf-8").splitlines(): + names.extend(line.replace(".class", "").replace(".xml", "").split("/")) + names.extend(["Sk8::CAC::MorphPreset", "Sk8::CAC::ColourPreset", "Sk8::CAC::MORPH_ZONES"]) + data = convert(staging / "skaterschema", staging / "skatercollections", names) + rows = [r for r in data["collections"] if r["class"].startswith(("cac_", "cas_"))] + return {"version": 1, "sources": sources, "collections": rows, "morphs": decode_morphs(rows)} diff --git a/tools/asset_pipeline/customisation_profiles.py b/tools/asset_pipeline/customisation_profiles.py new file mode 100644 index 00000000..907fc00e --- /dev/null +++ b/tools/asset_pipeline/customisation_profiles.py @@ -0,0 +1,58 @@ +"""Map owned native CAC profiles into named, directly applicable menu choices.""" +import json,struct,re +from pathlib import Path +from tools.asset_pipeline.customisation_native import key_hash +from tools.asset_pipeline.customisation_worker import ROOT + +def generate(native): + def page(name,children):return dict(label=name,children=children) + def choice(name,patch,**extra):return dict(label=name,patch=patch,**extra) + targets={key_hash(m['key']):m for m in native['morphs']} + presets=[] + for row in sorted(native['collections'],key=lambda r:r['key']): + if row['class']!='cac_presets' or not re.fullmatch(r'(male|female)_\d+',row['key']):continue + values={} + for field in row['fields'].values(): + if field['type']!='Sk8::CAC::MorphPreset':continue + for item in field['array']['items']: + raw=bytes.fromhex(item);target=targets[struct.unpack_from('>Q',raw,8)[0]] + normalized=struct.unpack_from('>f',raw,24)[0] + if not 0<=normalized<=1:raise ValueError('Preset is outside normalized range') + bounds=target['branch_a'] + # Inverse of CASMorph normalization8253CD18, preserving target + # hashes rather than assuming VLT array order matches UI order. + values[target['target']]=bounds['min']+normalized*(bounds['max']-bounds['min']) + if len(values)!=19:raise ValueError('Incomplete native face preset') + gender,number=row['key'].split('_') + presets.append(choice('Face '+str(int(number)),{'morphs':values},gender=gender)) + source=(ROOT/'crates/skate-game/src/graph_host/motion_character_gesture.rs').read_text() + names=re.findall(r'"B_GSTR_([A-Z_]+)"',source) + if len(names)!=37:raise ValueError('Native gesture table changed') + labels=['Air guitar','Airplane','Boxing','Bruce Lee','Check the time','Devil horns','Finger guns','Dunno', + 'Finger wag','Fists','Flex','Flip the table','The Fonz','Freedom','Raised fist','Get away','Get outta here', + 'Handcuffs','High fist pump','Low fist pump','Wind up','Peace','Point','Raise the roof','Shaka','Shrug', + 'Point to the sky','Snap','Soul arch','Spock','Surf\'s up','Swing high','Swing low','Throw your arms', + 'Thumbs down','Wings','Yard sale'] + gestures=page('Gestures',[page(direction,[choice(label,{'gestures':{str(i):j}}) for j,label in enumerate(labels)]) + for i,direction in enumerate(['Up','Down','Left','Right'])]) + skin_labels={'skin_light_3':'Porcelain','skin_light_2':'Fair','skin_light_4':'Warm', + 'skin_light_6':'Tan','skin_dark_1':'Brown','skin_dark_3':'Deep brown','skin_dark_2':'Dark'} + skin=[];hair=[] + for row in native['collections']: + if row['class']!='cac_colour':continue + fields=row['fields'];key=row['key'] + if key in skin_labels: + rgb=struct.unpack('>4f',bytes.fromhex(fields['Hash_7827ED970A88B70C']['data']))[:3] + skin.append(choice(skin_labels[key],{'skin':fields['Hash_AB0E65FA2F485ED8']['data'],'skin_tint':rgb})) + elif key.endswith('_hair') and not key.startswith('default') and not key.endswith('_facial_hair'): + rgb=struct.unpack('>4f',bytes.fromhex(fields['Hash_97462E6ABC78A423']['data']))[:3] + hair.append(choice(key[:-5].replace('_',' ').title(),{'hair_tint':rgb})) + skin.sort(key=lambda e: -sum(e['patch']['skin_tint'])) + hair.sort(key=lambda e:e['label']) + return [page('Skin tone',skin),page('Hair colour',hair),page('Face presets',presets),page('Style',[gestures, + page('Stance',[choice('Regular',{'stance':0}),choice('Goofy',{'stance':1})]), + page('Skating style',[choice(label,{'style':i}) for i,label in enumerate(['Standard','Loose','Gonzo','Aggressive'])])])] + +if __name__=='__main__': + import sys + root=Path(sys.argv[1]);(root/'extra-menu.json').write_text(json.dumps(generate(json.loads((root/'native.json').read_text())),indent=2)) 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_catalog.py b/tools/asset_pipeline/test_customisation_catalog.py new file mode 100644 index 00000000..428645fd --- /dev/null +++ b/tools/asset_pipeline/test_customisation_catalog.py @@ -0,0 +1,130 @@ +"""Synthetic CAC records only; no game content is needed or stored here.""" +from pathlib import Path +import struct +import tempfile +import unittest + +from tools.asset_pipeline.customisation_catalog import ( + MODEL_PREFIX, TEXTURE_PREFIX, parse_catalog, prepare, + selected_paths, write_private, +) +from tools.asset_pipeline.customisation_native import MORPH_FIELDS, decode_morphs + + +M1 = "0000000000000001" +M2 = "0000000000000002" +MODEL = "0000000000000020" +ASSET = "0000000000000030" +TEX1 = "0000000000000040" +TEX2 = "0000000000000050" + + +def fixture(): + xml = f''' + + + + + + + + + + + + '''.encode() + paths = {f"{MODEL_PREFIX}/Head/0x{MODEL}.rx2", + f"{TEXTURE_PREFIX}/0x{TEX1}.rx2", f"{TEXTURE_PREFIX}/0x{TEX2}.rx2"} + selection = [{"slot": "Head", "asset_id": ASSET, "lod": 0, "material_ids": [M1, M2]}] + return xml, paths, selection + + +class CatalogTests(unittest.TestCase): + def test_preserves_material_instance_order_and_authored_shader_inputs(self): + xml, paths, selection = fixture() + catalog = parse_catalog(xml, paths) + lod = catalog["components"][0]["models"][0]["lods"][0] + self.assertEqual(lod["model_id"], MODEL) # arenaid names the RX2, not id + self.assertEqual([group[0]["id"] for group in lod["material_instances"]], [M1, M2]) + definition = catalog["materials"][M1]["authored"]["children"] + self.assertEqual(definition[1]["attributes"]["sampler"], "authored") + self.assertEqual(definition[1]["attributes"]["uv"], "1") + self.assertEqual(definition[2]["attributes"]["value"], "0.375") + self.assertEqual(catalog["materials"][M1]["flags"]["cas.RequiresRemovalOfComponents"], "Hat,Hair") + self.assertEqual(set(selected_paths(catalog, selection)), paths) + + def test_rejects_incomplete_or_reordered_material_selection(self): + xml, paths, selection = fixture() + catalog = parse_catalog(xml, paths) + for choices in ([M1], [M2, M1], [M1, "000000000000ffff"]): + selection[0]["material_ids"] = choices + with self.assertRaises(ValueError): + selected_paths(catalog, selection) + + def test_missing_archived_texture_is_reported_and_cannot_be_extracted(self): + xml, paths, selection = fixture() + paths.remove(f"{TEXTURE_PREFIX}/0x{TEX2}.rx2") + catalog = parse_catalog(xml, paths) + self.assertFalse(catalog["materials"][M2]["textures"][0]["in_archive"]) + with self.assertRaisesRegex(ValueError, "Selected texture is absent"): + selected_paths(catalog, selection) + + def test_rejects_broken_material_references_and_path_slots(self): + xml, paths, _ = fixture() + broken = xml.replace(f'i", index).hex()}, + "ui_zone": {"type": "zone", "data": struct.pack(">I", 18 - index).hex()}} + for branch in ("a", "b"): + for name, number in (("min", 0.0), ("max", 0.5), ("default", 0.25)): + values[f"{name}_{branch}"] = {"type": "EA::Reflection::Float", "data": struct.pack(">f", number).hex()} + rows.append({"class": "cac_morph_params", "key": f"field_{index}", "fields": { + f"Hash_{MORPH_FIELDS[name]:016X}": value for name, value in values.items()}}) + return rows + + +class NativeMetadataTests(unittest.TestCase): + def test_target_order_and_ui_zone_remain_independent(self): + decoded = decode_morphs(list(reversed(morph_rows()))) + self.assertEqual(decoded[3]["target_order"], 3) + self.assertEqual(decoded[3]["ui_zone"], 15) + self.assertEqual(decoded[3]["branch_a"], {"min": 0.0, "max": 0.5, "default": 0.25}) + + def test_rejects_missing_targets_and_nonfinite_ranges(self): + with self.assertRaisesRegex(ValueError, "incomplete or duplicated"): + decode_morphs(morph_rows()[:-1]) + rows = morph_rows() + rows[3]["fields"][f"Hash_{MORPH_FIELDS['max_a']:016X}"]["data"] = "7fc00000" + with self.assertRaisesRegex(ValueError, "Non-finite"): + decode_morphs(rows) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/asset_pipeline/test_customisation_library.py b/tools/asset_pipeline/test_customisation_library.py new file mode 100644 index 00000000..6d206665 --- /dev/null +++ b/tools/asset_pipeline/test_customisation_library.py @@ -0,0 +1,95 @@ +"""Offline CAC preparation checks; no window, game process, or renderer.""" +import json +import os +import struct +import unittest +from pathlib import Path + +import numpy as np + +from tools.asset_pipeline.customisation_library import friendly, variant_label +from tools.asset_pipeline.retail_character import RX2 + + +class LibraryTests(unittest.TestCase): + def test_secondary_uv_is_float2_not_four_normalized_shorts(self): + data = struct.pack('>4f', .125, .75, .875, .625) + description = {'stride': 8, 'elements': [{'usage_name': 'TEXCOORD', + 'usage_index': 1, 'format_name': RX2.VTX_FORMATS[0x002C23A5][0], 'offset': 0}]} + self.assertEqual(RX2._decode_vertices(data, 0, 2, description)['uvs2'], + [(.125, .75), (.875, .625)]) + + def test_menu_names_remove_exporter_words_and_explain_wear(self): + self.assertEqual(friendly('male_longsleeve_tshirt_stampable_sleeves_up'), 'Rolled-Sleeve Tee') + self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material_d'), 'Worn') + self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material'), 'New') + self.assertEqual(friendly('tattoo_biglizard_mat'), 'Big Lizard') + + @unittest.skipUnless(os.environ.get('SKATE_CAC_TEST_LIBRARY'), 'owned library is opt-in') + def test_owned_geometry_and_dependencies(self): + path = Path(os.environ['SKATE_CAC_TEST_LIBRARY']) + library = json.loads(path.read_text(encoding='utf-8')) + assets = path.parents[2] + self.assertEqual(library['errors'], []) + self.assertEqual(len(library['models']), 480) + self.assertEqual(len(library['tattoos']), 170) + for part in library['models'].values(): + data = (assets / part['scene']).read_bytes() + magic, version, size, text_size = struct.unpack_from('<4I', data) + self.assertEqual((magic, version, size), (0x46546C67, 2, len(data))) + doc = json.loads(data[20:20+text_size]) + blob = data[28+text_size:] + + def array(index): + accessor = doc['accessors'][index] + view = doc['bufferViews'][accessor['bufferView']] + width = {'SCALAR':1, 'VEC2':2, 'VEC3':3, 'VEC4':4, 'MAT4':16}[accessor['type']] + result = np.frombuffer(blob, dtype={5126:'=left)&(uv[:,0]<=right)&(uv[:,1]>=1-top)&(uv[:,1]<=1-bottom) + self.assertTrue(inside.any(),part['name']+' '+side) + for material in library['materials'].values(): + for channel in ['diffuse','normal','rough','opacity']: + if material.get(channel): self.assertTrue((assets/material[channel]).is_file()) + # V3 preserves the native hair opacity texture independently of diffuse. + hair = [m for m in library['materials'].values() if m.get('opacity')] + self.assertEqual(len(hair), 31) + for material in hair: + self.assertNotEqual(material['diffuse'], material['opacity']) + self.assertIn('/decoded/', material['diffuse']) + self.assertTrue(material['alpha']) + # Native moussed-up hair demonstrates why diffuse A cannot be coverage. + from PIL import Image + sample = library['materials']['00000c5603e38817'] + diffuse = np.asarray(Image.open(assets/sample['diffuse']).convert('RGBA')) + opacity = np.asarray(Image.open(assets/sample['opacity']).convert('RGBA')) + self.assertLess(np.mean(diffuse[:,:,3] >= 128), .01) + self.assertGreater(np.mean(opacity[:,:,0] >= 128), .4) + for tattoo in library['tattoos'].values(): + self.assertTrue((assets/tattoo['texture']).is_file()) + + +if __name__ == '__main__': + unittest.main() 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/asset_pipeline/test_vlt.py b/tools/asset_pipeline/test_vlt.py new file mode 100644 index 00000000..39e3583d --- /dev/null +++ b/tools/asset_pipeline/test_vlt.py @@ -0,0 +1,44 @@ +"""Synthetic fixtures for the binary array layout; contains no game data.""" +import struct +import unittest +from .vlt import array_items, array_text_items + + +class ArrayLayoutTests(unittest.TestCase): + def test_vector4_header_alignment(self): + first = struct.pack('>4f', .75, .35, 0, 0) + second = struct.pack('>4f', 1, 1, 1, 1) + raw = struct.pack('>4H', 2, 2, 16, 0x8000) + bytes(8) + first + second + result = array_items(raw, 0, 16, 4) + self.assertEqual(result['items'], [first.hex().upper(), second.hex().upper()]) + self.assertEqual(result['alignment'], 16) + + def test_each_element_is_aligned_at_absolute_offset(self): + a, b = struct.pack('>3f', 2, 3, 4), struct.pack('>3f', 5, 6, 7) + raw = bytes(4) + struct.pack('>4H', 3, 2, 12, 0) + bytes(4) + raw += a + bytes(4) + b + bytes(4) + bytes(12) + result = array_items(raw, 4, 12, 4) + self.assertEqual(result['capacity'], 3) + self.assertEqual(result['items'], [a.hex().upper(), b.hex().upper()]) + + def test_empty_and_pointer_arrays(self): + self.assertEqual(array_items(struct.pack('>4H', 0, 0, 4, 0), 0, 4, 2)['items'], []) + raw = struct.pack('>4H2I', 2, 2, 4, 0, 0x1234, 0) + self.assertEqual(array_items(raw, 0, 4, 2)['items'], ['00001234', '00000000']) + + def test_invalid_header_or_truncated_capacity_fails(self): + for raw in [struct.pack('>4H', 1, 2, 4, 0), struct.pack('>4H', 1, 1, 8, 0), + struct.pack('>4HI', 2, 1, 4, 0, 123)]: + with self.assertRaises(ValueError): + array_items(raw, 0, 4, 2) + + def test_text_array_resolves_relocated_pointers(self): + binary = bytes(8) + 'test\0caf\u00e9\0'.encode() + self.assertEqual(array_text_items(binary, ['00000008', '0000000D', '00000000']), + ['test', 'caf\u00e9', '']) + with self.assertRaises(ValueError): + array_text_items(binary, ['0000FFFF']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/asset_pipeline/vlt.py b/tools/asset_pipeline/vlt.py index 930adaa9..dda19732 100644 --- a/tools/asset_pipeline/vlt.py +++ b/tools/asset_pipeline/vlt.py @@ -43,6 +43,42 @@ def take(data, at, size): def unpack(fmt,data,at): return struct.unpack('>'+fmt,take(data,at,struct.calcsize('>'+fmt))) +def array_items(data, pos, size, alignment): + """ModernBase array: four u16 header lanes, then aligned elements. + + Alignment in the schema is log2(bytes), not the array header's mask. + Retain each element separately: padding is not part of its value. + """ + capacity, count, stride, _ = unpack('4H', data, pos) + if count > capacity or stride != size or not stride or alignment > 16: + raise ValueError(f'Invalid VLT array at {pos:#x}') + boundary = 1 << alignment + cursor = pos + 8 + items = [] + for index in range(capacity): + cursor = (cursor + boundary - 1) & -boundary + raw = take(data, cursor, stride) + if index < count: + items.append(raw.hex().upper()) + cursor += stride + return {'capacity': capacity, 'element_size': stride, + 'alignment': boundary, 'items': items} + + +def array_text_items(binary, items): + values = [] + for raw in items: + ptr, = unpack('I', bytes.fromhex(raw), 0) + if not ptr: + values.append('') + continue + end = binary.find(b'\0', ptr) + if end < 0: + raise ValueError(f'Unterminated VLT array text at {ptr:#x}') + values.append(take(binary, ptr, end-ptr).decode('utf-8')) + return values + + def chunks(data): at=0 while at "%~dp0customiser-v4-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-v4-session.log + pause +) +exit /b %skate_customiser_exit% diff --git a/tools/vendor/skate3_anim/PROVENANCE.md b/tools/vendor/skate3_anim/PROVENANCE.md index 5db56627..61171203 100644 --- a/tools/vendor/skate3_anim/PROVENANCE.md +++ b/tools/vendor/skate3_anim/PROVENANCE.md @@ -12,3 +12,8 @@ OffBoard clips to the same armature and action bank. They decode animation and skeleton files supplied locally by the user. No retail payload is included. + +Local CAC correction: vertex format `0x002C23A5` now decodes as two big-endian +float32 values. Its Xenos surface-format field is 37 (32_32_FLOAT); the previous +SHORT4N interpretation corrupted secondary tattoo UVs. Source declarations, +owned-mesh UV bounds and synthetic decoding tests corroborate the change. diff --git a/tools/vendor/skate3_anim/rx2_skeleton.py b/tools/vendor/skate3_anim/rx2_skeleton.py index 6362112c..e0c3dd6c 100644 --- a/tools/vendor/skate3_anim/rx2_skeleton.py +++ b/tools/vendor/skate3_anim/rx2_skeleton.py @@ -398,7 +398,7 @@ VTX_FORMATS: Dict[int, Tuple[str, int]] = { 0x001A215A: ("SHORT4", 8), # raw int16[4] -- position 0x001A2286: ("UBYTE4", 4), # raw uint8[4] -- blend indices / weights 0x002C2159: ("SHORT2N", 4), # int16[2] / 32768 -- texcoord - 0x002C23A5: ("SHORT4N", 8), # int16[4] / 32768 -- texcoord pair + 0x002C23A5: ("FLOAT2", 8), # Xenos format37: two big-endian float32 UVs 0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal } @@ -514,6 +514,9 @@ def _decode_vertices(d: bytes, vb: int, count: int, vdesc: dict) -> dict: for v in range(count): u, w = struct.unpack_from(">2h", d, vb + v * stride + off) vals.append((u / 32768.0, w / 32768.0)) + elif fmt == "FLOAT2": + for v in range(count): + vals.append(struct.unpack_from(">2f", d, vb + v * stride + off)) elif fmt == "SHORT4N": for v in range(count): q = struct.unpack_from(">4h", d, vb + v * stride + off)