Add playable customiser test build with transactional retail character swaps

This commit is contained in:
chazmm committed 2026-09-08 15:51:15 +01:00
1 parent 9ca5ee076f
commit a2ead83471
14 files changed
+1035 -36

No files matched your search

+1
View File
@@ -101,6 +101,7 @@ pub(crate) fn build(
crate::grind_world::GrindGeometryPlugin, crate::grind_world::GrindGeometryPlugin,
camera::CameraPlugin, camera::CameraPlugin,
crate::graphics_menu::GraphicsMenuPlugin, crate::graphics_menu::GraphicsMenuPlugin,
crate::customiser::CustomiserPlugin,
crate::render_capacity::RenderCapacityPlugin, crate::render_capacity::RenderCapacityPlugin,
verification::VerificationPlugin, verification::VerificationPlugin,
crate::performance::PerformancePlugin, crate::performance::PerformancePlugin,
+9
View File
@@ -52,6 +52,7 @@ fn spawn(mut commands: Commands, config: Res<Config>, retail: Res<crate::retail_
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>, fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>, history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
replay: Res<crate::replay::Replay>, replay: Res<crate::replay::Replay>,
customiser: Option<Res<crate::customiser::Customiser>>,
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) { mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
if let Ok(window) = windows.single() { if let Ok(window) = windows.single() {
runtime.set_aspect_ratio(window.width() / window.height()); runtime.set_aspect_ratio(window.width() / window.height());
@@ -65,6 +66,14 @@ fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
if let Projection::Perspective(p) = &mut *projection { if let Projection::Perspective(p) = &mut *projection {
p.fov = previous.fov + (current.fov - previous.fov) * alpha; p.fov = previous.fov + (current.fov - previous.fov) * alpha;
} }
if customiser.as_ref().is_some_and(|c| c.open) {
let center = current.root.translation + Vec3::Y * 0.95;
let offset = current.root.rotation * Vec3::new(0.0, 0.25, 3.2);
let eye = center + offset;
let right = offset.normalize().cross(Vec3::Y).normalize();
*transform = Transform::from_translation(eye).looking_at(center + right * 0.85, Vec3::Y);
if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); }
}
camera.is_active = true; camera.is_active = true;
} }
} }
+601
View File
@@ -0,0 +1,601 @@
//! Draft/commit character editing over the existing retail GLB and animation rig.
use bevy::{asset::LoadState, gltf::Gltf, prelude::*};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{
io::Write,
path::PathBuf,
process::{Command, Stdio},
sync::{Mutex, mpsc},
};
#[derive(Clone, Default, Deserialize)]
struct Entry {
label: String,
#[serde(default)]
children: Vec<Entry>,
patch: Option<Value>,
scalar: Option<String>,
note: Option<String>,
minimum: Option<f64>,
maximum: Option<f64>,
initial: Option<f64>,
step: Option<f64>,
}
#[derive(Clone, Deserialize)]
struct Worker {
python: PathBuf,
working_directory: PathBuf,
}
#[derive(Resource, Default)]
pub(crate) struct Navigation {
pub pressed: u16,
previous: u16,
}
#[derive(Resource)]
pub(crate) struct Customiser {
pub open: bool,
just_opened: bool,
index: Entry,
path: Vec<usize>,
selected: usize,
draft: Value,
committed: Value,
scene: String,
committed_scene: String,
config: PathBuf,
settings: PathBuf,
worker: Option<Worker>,
receiver: Option<Mutex<mpsc::Receiver<Result<Value, String>>>>,
loading: Option<(Handle<Gltf>, String, Value)>,
status: String,
redraw: bool,
}
impl Customiser {
pub(crate) fn begin(&mut self) {
self.open = true;
self.just_opened = true;
self.path.clear();
self.selected = 0;
self.status = "Choose a category. Changes are previews until you Save.".into();
self.redraw = true;
}
fn page(&self) -> &Entry {
let mut page = &self.index;
for &i in &self.path {
page = &page.children[i];
}
page
}
fn busy(&self) -> bool {
self.receiver.is_some() || self.loading.is_some()
}
fn request(&mut self, profile: Value) {
if self.busy() {
self.status = "Please wait for the current preview.".into();
return;
}
let Some(worker) = self.worker.clone() else {
self.status = "Private customisation worker is not configured.".into();
return;
};
let config = self.config.clone();
let (tx, rx) = mpsc::channel();
self.receiver = Some(Mutex::new(rx));
self.status = "Preparing retail meshes and textures…".into();
self.redraw = true;
std::thread::spawn(move || {
let run = || -> Result<Value, String> {
let mut command = Command::new(&worker.python);
command
.current_dir(&worker.working_directory)
.args([
"-m",
"tools.asset_pipeline.customisation_worker",
"--config",
])
.arg(config)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let mut child = command
.spawn()
.map_err(|e| format!("Cannot start asset worker: {e}"))?;
child
.stdin
.take()
.ok_or("Worker stdin missing")?
.write_all(&serde_json::to_vec(&profile).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let output = child.wait_with_output().map_err(|e| e.to_string())?;
let response: Value = serde_json::from_slice(&output.stdout).map_err(|e| {
format!(
"Worker response: {e}; {}",
String::from_utf8_lossy(&output.stderr)
.chars()
.take(400)
.collect::<String>()
)
})?;
if let Some(e) = response.get("error").and_then(Value::as_str) {
return Err(e.into());
}
if !output.status.success() {
return Err("Asset worker failed".into());
}
Ok(response)
};
let _ = tx.send(run());
});
}
}
#[derive(Component)]
struct Root;
#[derive(Component)]
struct Row(usize);
pub(crate) struct CustomiserPlugin;
impl Plugin for CustomiserPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Navigation>()
.add_systems(PreUpdate, navigation)
.add_systems(PostStartup, setup)
.add_systems(
Update,
(interact, complete, draw)
.chain()
.after(crate::graphics_menu::interact)
.before(crate::app::FrameSet::Animation),
);
}
}
fn navigation(mut nav: ResMut<Navigation>) {
let current = (0..4)
.find_map(|i| crate::input::platform::poll(i).ok())
.map_or(0, |p| p.state.buttons);
nav.pressed = current & !nav.previous;
nav.previous = current;
}
fn setup(
mut commands: Commands,
config: Res<crate::config::Config>,
manifest: Res<crate::assets::AssetManifest>,
) {
let root = config.asset_root.join("private/customisation");
let read = |name: &str| {
std::fs::read(root.join(name))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
};
let index = read("menu.json")
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or(Entry {
label: "Character customiser".into(),
note: Some("Prepare owned customisation assets for this installation.".into()),
..default()
});
let defaults = read("default.json")
.unwrap_or(json!({"selections":{},"morphs":{},"truck":0.7,"wheel":0.7,"posture":0}));
let settings = config
.asset_root
.parent()
.unwrap_or(&config.asset_root)
.join("settings/character.json");
let saved = std::fs::read(&settings)
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok());
let worker = read("worker.json").and_then(|v| serde_json::from_value(v).ok());
let mut state = Customiser {
open: false,
just_opened: false,
index,
path: vec![],
selected: 0,
draft: defaults.clone(),
committed: defaults,
scene: manifest.0.character_scene.clone(),
committed_scene: manifest.0.character_scene.clone(),
config: root.join("worker.json"),
settings,
worker,
receiver: None,
loading: None,
status: String::new(),
redraw: true,
};
if let Some(profile) = saved {
state.committed = profile.clone();
state.request(profile);
}
commands.insert_resource(state);
commands.spawn((
Root,
GlobalZIndex(20),
Node {
display: Display::None,
position_type: PositionType::Absolute,
width: percent(42),
height: percent(100),
padding: UiRect::all(px(20)),
flex_direction: FlexDirection::Column,
row_gap: px(6),
..default()
},
BackgroundColor(Color::srgba(0.025, 0.04, 0.06, 0.98)),
));
}
fn merge(target: &mut Value, patch: &Value) {
if let (Some(a), Some(b)) = (target.as_object_mut(), patch.as_object()) {
for (k, v) in b {
merge(a.entry(k.clone()).or_insert(Value::Null), v);
}
} else {
*target = patch.clone();
}
}
fn scalar(profile: &Value, key: &str, initial: f64) -> f64 {
let pointer = format!("/{}", key.replace('.', "/"));
profile
.pointer(&pointer)
.and_then(Value::as_f64)
.unwrap_or(initial)
}
fn interact(
keys: Res<ButtonInput<KeyCode>>,
nav: Res<Navigation>,
mut state: ResMut<Customiser>,
mut menu: ResMut<crate::graphics_menu::Menu>,
server: Res<AssetServer>,
buttons: Query<(&Interaction, &Row), Changed<Interaction>>,
) {
if !state.open {
return;
}
if state.just_opened {
state.just_opened = false;
return;
}
let mut action = None;
let count = state.page().children.len();
if count > 0 {
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
state.selected = (state.selected + count - 1) % count;
state.redraw = true;
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
state.selected = (state.selected + 1) % count;
state.redraw = true;
}
if keys.just_pressed(KeyCode::Enter)
|| keys.just_pressed(KeyCode::ArrowRight)
|| nav.pressed & (0x1000 | 8) != 0
{
action = Some((state.selected, 1));
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some((state.selected, -1));
}
}
let mut save = keys.just_pressed(KeyCode::F5) || nav.pressed & 0x4000 != 0;
let mut back = keys.just_pressed(KeyCode::Escape)
|| keys.just_pressed(KeyCode::Backspace)
|| nav.pressed & 0x2000 != 0;
let mut reset = false;
for (interaction, row) in &buttons {
if *interaction == Interaction::Pressed {
match row.0 {
usize::MAX => save = true,
x if x == usize::MAX - 1 => back = true,
x if x == usize::MAX - 2 => reset = true,
i => {
state.selected = i;
action = Some((i, 1));
}
}
}
}
if save {
if state.busy() {
state.status = "Wait for the preview before saving.".into();
} else {
let result = (|| -> Result<(), String> {
std::fs::create_dir_all(state.settings.parent().unwrap())
.map_err(|e| e.to_string())?;
let temp = state.settings.with_extension("pending.json");
std::fs::write(
&temp,
serde_json::to_vec_pretty(&state.draft).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
// Same directory replacement preserves the previous committed profile on write failure.
std::fs::rename(&temp, &state.settings).map_err(|e| e.to_string())
})();
match result {
Ok(()) => {
state.committed = state.draft.clone();
state.committed_scene = state.scene.clone();
state.status = "Saved. Back returns to skating with this character.".into();
}
Err(e) => state.status = format!("Could not save: {e}"),
}
}
state.redraw = true;
}
if reset && !state.busy() {
let profile = std::fs::read(state.config.with_file_name("default.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
if let Some(profile) = profile {
state.request(profile);
}
}
if back {
if !state.path.is_empty() {
state.selected = state.path.pop().unwrap();
} else if state.busy() {
state.status = "Wait for the preview before leaving.".into();
} else {
if state.draft != state.committed {
let scene = state.committed_scene.clone();
let profile = state.committed.clone();
state.loading = Some((server.load(scene.clone()), scene, profile));
}
state.open = false;
menu.open = false;
}
state.redraw = true;
return;
}
if let Some((i, direction)) = action {
let Some(entry) = state.page().children.get(i).cloned() else {
return;
};
if !entry.children.is_empty() {
state.path.push(i);
state.selected = 0;
} else if let Some(note) = entry.note {
state.status = note;
} else if !state.busy() {
let mut profile = state.draft.clone();
if let Some(key) = entry.scalar {
let current = scalar(&profile, &key, entry.initial.unwrap_or(0.));
let value = (current + direction as f64 * entry.step.unwrap_or(0.1))
.clamp(entry.minimum.unwrap_or(0.), entry.maximum.unwrap_or(1.));
if let Some((parent, child)) = key.split_once('.') {
profile[parent][child] = json!(value);
} else {
profile[&key] = json!(value);
}
// Equipment controls are consumed directly by existing native physics.
if key == "truck" || key == "wheel" {
state.draft = profile;
} else {
state.request(profile);
}
} else if let Some(patch) = entry.patch {
merge(&mut profile, &patch);
if patch.get("posture").is_some() {
state.draft = profile;
state.status="Posture is selected. The native motion tree applies it when skating resumes.".into();
} else {
state.request(profile);
}
}
} else {
state.status = "Please wait for the current preview.".into();
}
state.redraw = true;
}
}
fn complete(
mut state: ResMut<Customiser>,
server: Res<AssetServer>,
mut commands: Commands,
root: Query<Entity, With<crate::world::PlayerRoot>>,
scenes: Query<(Entity, &ChildOf), With<SceneRoot>>,
mut animation: ResMut<crate::animation::AnimationStatus>,
mut physics: ResMut<crate::physics::GamePhysics>,
mut skater: ResMut<crate::physics::SkaterRuntime>,
) {
let response = state
.receiver
.as_ref()
.and_then(|rx| rx.lock().ok()?.try_recv().ok());
if let Some(response) = response {
state.receiver = None;
state.redraw = true;
match response {
Ok(value) => {
if let (Some(scene), Some(profile)) = (
value.get("scene").and_then(Value::as_str),
value.get("profile"),
) {
state.loading =
Some((server.load(scene.to_owned()), scene.into(), profile.clone()));
state.status = "Loading the assembled character…".into();
} else {
state.status = "Asset worker returned an incomplete character.".into();
}
}
Err(e) => state.status = format!("Preview unchanged: {e}"),
}
}
if let Some((handle, _, _)) = &state.loading {
if let Some(LoadState::Failed(error)) = server.get_load_state(handle.id()) {
state.status = format!("Preview unchanged: {error}");
state.loading = None;
state.redraw = true;
} else if server.is_loaded_with_dependencies(handle.id()) {
if let Ok(root) = root.single() {
let (_, scene, profile) = state.loading.take().unwrap();
for (entity, parent) in &scenes {
if parent.parent() == root {
commands.entity(entity).despawn();
}
}
commands.entity(root).with_children(|p| {
p.spawn(SceneRoot(
server.load(GltfAssetLabel::Scene(0).from_asset(scene.clone())),
));
});
*animation = default();
if profile == state.committed {
state.committed_scene = scene.clone();
}
state.scene = scene;
state.draft = profile;
state.status = "Preview ready. Save to keep, or Back at the top to discard.".into();
state.redraw = true;
}
}
}
physics.set_equipment_preferences(
scalar(&state.draft, "truck", 0.7).clamp(0., 1.) as f32,
scalar(&state.draft, "wheel", 0.7).clamp(0., 1.) as f32,
);
skater
.animation
.motion
.animation
.posture
.set_profile(scalar(&state.draft, "posture", 0.).clamp(0., 3.) as u32);
}
fn draw(
mut commands: Commands,
mut state: ResMut<Customiser>,
mut root: Single<(Entity, &mut Node), With<Root>>,
) {
root.1.display = if state.open {
Display::Flex
} else {
Display::None
};
if !state.open || !state.redraw {
return;
}
state.redraw = false;
commands.entity(root.0).despawn_children();
let page = state.page();
let start = state.selected / 10 * 10;
commands.entity(root.0).with_children(|p| {
p.spawn((
Text::new(&page.label),
TextFont {
font_size: 25.,
..default()
},
TextColor(Color::WHITE),
));
p.spawn((
Text::new("RETAIL CHARACTER • TEST BUILD"),
TextFont {
font_size: 13.,
..default()
},
TextColor(Color::srgb(0.4, 0.85, 0.8)),
));
for (i, entry) in page.children.iter().enumerate().skip(start).take(10) {
let value = entry
.scalar
.as_ref()
.map(|key| {
format!(
" {:.3}",
scalar(&state.draft, key, entry.initial.unwrap_or(0.))
)
})
.unwrap_or_default();
let suffix = if !entry.children.is_empty() {
" >"
} else {
""
};
p.spawn((
Button,
Row(i),
Node {
width: percent(100),
min_height: px(32),
padding: UiRect::all(px(7)),
..default()
},
BackgroundColor(if state.selected == i {
Color::srgb(0.08, 0.32, 0.33)
} else {
Color::srgb(0.06, 0.10, 0.14)
}),
))
.with_children(|r| {
r.spawn((
Text::new(format!("{}{value}{suffix}", entry.label)),
TextFont {
font_size: 16.,
..default()
},
TextColor(Color::WHITE),
));
});
}
if page.children.len() > 10 {
p.spawn((
Text::new(format!(
"{}–{} / {} • Up/Down to browse",
start + 1,
(start + 10).min(page.children.len()),
page.children.len()
)),
TextFont {
font_size: 13.,
..default()
},
));
}
for (id, title) in [
(usize::MAX, "Save [F5 / X]"),
(usize::MAX - 1, "Back [Esc / B]"),
(usize::MAX - 2, "Restore original character"),
] {
p.spawn((
Button,
Row(id),
Node {
padding: UiRect::all(px(7)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.17, 0.21)),
))
.with_children(|r| {
r.spawn((
Text::new(title),
TextFont {
font_size: 16.,
..default()
},
TextColor(Color::WHITE),
));
});
}
p.spawn((
Text::new(&state.status),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::srgb(0.8, 0.85, 0.9)),
));
p.spawn((
Text::new(
"Enter / A: choose • Left/Right: adjust\nBack at the top discards unsaved changes.",
),
TextFont {
font_size: 13.,
..default()
},
TextColor(Color::srgb(0.55, 0.65, 0.7)),
));
});
}
+19 -13
View File
@@ -75,7 +75,7 @@ impl GraphicsSettings {
} }
#[derive(Resource)] #[derive(Resource)]
pub(crate) struct Menu { pub(crate) struct Menu {
open: bool, pub(crate) open: bool,
selected: usize, selected: usize,
settings: GraphicsSettings, settings: GraphicsSettings,
path: PathBuf, path: PathBuf,
@@ -95,7 +95,7 @@ struct FramePacer(Instant);
#[derive(Component)] #[derive(Component)]
struct MenuRoot; struct MenuRoot;
#[derive(Component)] #[derive(Component)]
struct MenuRow(usize); pub(crate) struct MenuRow(usize);
#[derive(Component)] #[derive(Component)]
struct MenuLabel(usize); struct MenuLabel(usize);
#[derive(Component)] #[derive(Component)]
@@ -198,7 +198,7 @@ fn setup(
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { 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("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)))); 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()}, 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| { 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))); row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
@@ -235,8 +235,10 @@ fn cycle<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32; let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32;
values[(index + direction).rem_euclid(values.len() as i32) as usize] values[(index + direction).rem_euclid(values.len() as i32) as usize]
} }
fn interact( pub(crate) fn interact(
config: Res<crate::config::Config>, config: Res<crate::config::Config>,
mut customiser: ResMut<crate::customiser::Customiser>,
nav: Res<crate::customiser::Navigation>,
mut physics: ResMut<crate::physics::GamePhysics>, mut physics: ResMut<crate::physics::GamePhysics>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut menu: ResMut<Menu>, mut menu: ResMut<Menu>,
@@ -244,21 +246,22 @@ fn interact(
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>, buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
mut exit: MessageWriter<AppExit>, mut exit: MessageWriter<AppExit>,
) { ) {
if keys.just_pressed(KeyCode::Escape) { if customiser.open { return; }
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
menu.open = !menu.open; menu.open = !menu.open;
} }
let mut action = None; let mut action = None;
if menu.open { if menu.open {
if keys.just_pressed(KeyCode::ArrowUp) { if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + 9) % 10; menu.selected = (menu.selected + 10) % 11;
} }
if keys.just_pressed(KeyCode::ArrowDown) { if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
menu.selected = (menu.selected + 1) % 10; 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)); 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)); action = Some((menu.selected, 1));
} }
for (interaction, row) in &buttons { for (interaction, row) in &buttons {
@@ -308,6 +311,7 @@ fn interact(
9 => { 9 => {
exit.write(AppExit::Success); exit.write(AppExit::Success);
} }
10 => customiser.begin(),
_ => {} _ => {}
} }
if row < 5 { if row < 5 {
@@ -380,13 +384,14 @@ fn apply(
} }
fn labels( fn labels(
menu: Res<Menu>, menu: Res<Menu>,
customiser: Res<crate::customiser::Customiser>,
window: Single<&Window, With<PrimaryWindow>>, window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>, mut root: Single<&mut Node, With<MenuRoot>>,
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>, mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
mut status: Single<&mut Text, With<StatusLabel>>, mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>, mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>,
) { ) {
root.display = if menu.open { root.display = if menu.open && !customiser.open {
Display::Flex Display::Flex
} else { } else {
Display::None Display::None
@@ -424,7 +429,8 @@ fn labels(
6 => format!("Map {}", menu.maps[menu.selected_map].label), 6 => format!("Map {}", menu.maps[menu.selected_map].label),
7 => "Load map (restarts session)".into(), 7 => "Load map (restarts session)".into(),
8 => "Resume".into(), 8 => "Resume".into(),
_ => "Quit game".into(), 9 => "Quit game".into(),
_ => "Character customiser".into(),
}; };
} }
***status = menu.status.clone(); ***status = menu.status.clone();
+1 -1
View File
@@ -7,7 +7,7 @@ pub(crate) mod gesture_catalog;
mod gesture_mapping_data; mod gesture_mapping_data;
pub(crate) mod gesture_mapping; pub(crate) mod gesture_mapping;
pub(crate) mod gesture_input; pub(crate) mod gesture_input;
mod platform; pub(crate) mod platform;
pub(crate) use controllers::{ControllerInput, ControllerStatus}; pub(crate) use controllers::{ControllerInput, ControllerStatus};
use skate_core::input::tick::TickInput; use skate_core::input::tick::TickInput;
+2 -2
View File
@@ -2,7 +2,7 @@
//! converter without Bevy/gilrs deadzones or normalized-axis reconstruction. //! converter without Bevy/gilrs deadzones or normalized-axis reconstruction.
use skate_core::input::xbox::XboxState; use skate_core::input::xbox::XboxState;
pub(super) struct DevicePacket { pub(crate) struct DevicePacket {
pub number: u32, pub number: u32,
pub state: XboxState, pub state: XboxState,
pub subtype: u8, pub subtype: u8,
@@ -95,7 +95,7 @@ mod windows {
} }
} }
pub(super) fn poll(index: usize) -> Result<DevicePacket, DeviceError> { pub(crate) fn poll(index: usize) -> Result<DevicePacket, DeviceError> {
assert!(index < 4); assert!(index < 4);
#[cfg(windows)] #[cfg(windows)]
return windows::poll(index as u32); return windows::poll(index as u32);
+1
View File
@@ -15,6 +15,7 @@ mod skater_animation;
mod verification; mod verification;
mod performance; mod performance;
mod graphics_menu; mod graphics_menu;
mod customiser;
mod render_capacity; mod render_capacity;
mod retail_render; mod retail_render;
mod presentation; mod presentation;
+6
View File
@@ -169,6 +169,12 @@ mod exchange_tests {
} }
impl GamePhysics { impl GamePhysics {
pub(crate) fn set_equipment_preferences(&mut self, truck: f32, wheel: f32) {
if truck.is_finite() && wheel.is_finite() {
self.animation_profile.truck_tightness = truck.clamp(0.0, 1.0);
self.animation_profile.wheel_hardness = wheel.clamp(0.0, 1.0);
}
}
pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) { pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) {
// Actor publication carries this selector into the next physical packet. // Actor publication carries this selector into the next physical packet.
// Keep the board, active trick, equipment preferences and controller history. // Keep the board, active trick, equipment preferences and controller history.
+45 -12
View File
@@ -1,10 +1,43 @@
# Character customisation: extraction boundary and native blockers # Character customisation test build
Status: **not a playable customiser**. The runtime, Escape menu, character A dedicated local executable and `Play Character Customiser.bat` are now prepared.
loader, physics defaults, and renderer are unchanged by this work. No dummy The game was **not launched**: validation here is compilation, synthetic unit tests,
controls or replacement models were added. A faithful implementation is and offline retail asset assembly. User playtesting is still required.
blocked on the CAC material/compositor contract and the remaining native
assembly/profile application described below. The Escape/Start menu opens a left-side character menu with the actual animated
skater framed on the right. Supported male model/material selections are assembled
on demand through the existing RX2/ABIN-to-GLB pipeline. The current character
stays visible during conversion or a failed request. Completed replacements reset
skin bindings before the animation system runs. Save commits the profile;
Back at the top discards unsaved changes. F5/controller X saves; Escape/controller
B goes back; arrows/D-pad select and adjust; Enter/controller A chooses.
Working paths in this test include hair, hats, authored skin/facial-hair material
variants, clothing and accessories, boards, all 19 individual body/face morph
weights, native truck/wheel ranges, and posture selection. Equipment retains
its 0.7 defaults. Posture takes effect through the next eligible native motion
construction when skating resumes. The original character loads unchanged when
there is no saved custom profile.
**This is not a complete retail CAC implementation.** Gender switching, facial
presets, tattoos, gestures, stance and style remain explicit informational pages.
The existing StandardMaterial shading is retained. Native stamp compositing,
colour-zone controls, unlock filtering, full material parity and all outfit
compatibility cases are unfinished. Authored asset names are shown where localized
retail display names have not been resolved. Do not describe this test as full
retail customiser parity.
`tools/asset_pipeline/customisation_worker.py` provides the JSON worker and menu
index. A private `worker.json` holds local Python, source and asset-root paths;
retail data stays outside Git. The private test overlay links the existing stock,
skies and maps and owns its character cache and settings. It does not overwrite
an existing game executable or require another installation/conversion.
Validation: 18 synthetic Python tests pass. Offline retail assemblies cover the
baseline, body/face changes, skin, beard, hair, hat, hoodie, pants, shoes, deck and
glasses. Packed 11/11/10 signed normal deltas accompany weighted body morphs;
normalization occurs after accumulating deltas. These checks are not a rendered
or interactive gameplay test.
## Implemented, private-data-only tooling ## Implemented, private-data-only tooling
@@ -151,16 +184,16 @@ CAC profile initialization must be traced before changing it.
both genders against authored component skinning/bind matrices. Preserve both genders against authored component skinning/bind matrices. Preserve
hair-under-hat variants, arm/leg substitutions, socks, layered inner/outer hair-under-hat variants, arm/leg substitutions, socks, layered inner/outer
tops, removed necklaces/wrist items and multiple material instances. The tops, removed necklaces/wrist items and multiple material instances. The
existing GLB pipeline accepts only the fixed fallback component manifest; test worker now supports male single-material mesh groups and applies the
extend it after the assembly contract is established. explicit garment/hair/removal flags, but full native sequence parity remains.
3. **Complete profile/menu semantics.** Apply parent-row inheritance and 3. **Complete profile/menu semantics.** Apply parent-row inheritance and
native face presets, colour zones, available-item/unlock filters, tattoos, native face presets, colour zones, available-item/unlock filters, tattoos,
gestures, stance, style and posture. Keep physical board adjustments gestures, stance, style and posture. Keep physical board adjustments
separate. Use actual saved defaults rather than constructor placeholders. separate. Use actual saved defaults rather than constructor placeholders.
4. **Runtime/UI.** Implement the requested hierarchy, left controls and right 4. **Runtime/UI validation.** The test implementation now includes navigation,
preview only for working native paths. Add transactional preview/apply/back, preview/save/back, persistence and in-process replacement. It still requires
coherent persistence, controller navigation and in-process character user gameplay testing, particularly replacement while paused, reopening saved
replacement. No runtime or menu integration is present in this commit. outfits and controller navigation. No game-based validation was run.
The requested hierarchy is recorded verbatim in `HIERARCHY`. It is a scope The requested hierarchy is recorded verbatim in `HIERARCHY`. It is a scope
contract, not a declaration that those pages work. The map-switch task's contract, not a declaration that those pages work. The map-switch task's
+8 -6
View File
@@ -32,8 +32,8 @@ class Glb:
path.write_bytes(struct.pack('<III',0x46546c67,2,28+len(text)+len(self.data))+ path.write_bytes(struct.pack('<III',0x46546c67,2,28+len(text)+len(self.data))+
struct.pack('<I4s',len(text),b'JSON')+text+struct.pack('<I4s',len(self.data),b'BIN\0')+self.data) struct.pack('<I4s',len(text),b'JSON')+text+struct.pack('<I4s',len(self.data),b'BIN\0')+self.data)
def convert(models,private,recipe): def convert(models,private,recipe,*,stock=None,output=None,materials=None):
source=AnimSource(private/'stock/data/anim/OnBoard.abin') source=AnimSource((stock or private/'stock')/'data/anim/OnBoard.abin')
skeleton=SkeletonSet(str(models)) skeleton=SkeletonSet(str(models))
if skeleton.errors:raise ValueError(str(skeleton.errors)) if skeleton.errors:raise ValueError(str(skeleton.errors))
mapping=skeleton.index_map(ABIN.BONE_NAMES,source.parents) mapping=skeleton.index_map(ABIN.BONE_NAMES,source.parents)
@@ -76,11 +76,13 @@ def convert(models,private,recipe):
raw=raw[0];positions=np.asarray(mesh['pos'],dtype=np.float64) raw=raw[0];positions=np.asarray(mesh['pos'],dtype=np.float64)
morphs=decode_dense_morphs(path,parsed,len(positions),RX2) morphs=decode_dense_morphs(path,parsed,len(positions),RX2)
if [m['name'] for m in morphs]!=recipe['morph_assembly']['expected_targets'][slot]:raise ValueError('Unexpected morph set '+slot) if [m['name'] for m in morphs]!=recipe['morph_assembly']['expected_targets'][slot]:raise ValueError('Unexpected morph set '+slot)
normals=np.asarray(raw['normals'],dtype=np.float64)
for morph in morphs: for morph in morphs:
weight=morph_weight(morph['name'],recipe) weight=morph_weight(morph['name'],recipe)
if weight and morph['nonzero_normal_deltas']:raise ValueError('Unsupported morph normals '+slot)
positions+=np.asarray(morph['deltas'])*weight positions+=np.asarray(morph['deltas'])*weight
normals=np.asarray(raw['normals'],dtype=np.float64);normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20) if weight:
normals+=np.asarray(morph['normal_deltas'])*weight
normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20)
joints=np.zeros((len(positions),4),dtype=np.uint16);weights=np.zeros((len(positions),4),dtype=np.float32) joints=np.zeros((len(positions),4),dtype=np.uint16);weights=np.zeros((len(positions),4),dtype=np.float32)
if not mesh['skin']:raise ValueError('Missing skin '+slot) if not mesh['skin']:raise ValueError('Missing skin '+slot)
for i,influences in enumerate(mesh['skin']): for i,influences in enumerate(mesh['skin']):
@@ -89,7 +91,7 @@ def convert(models,private,recipe):
if len(combined)>4 or not combined:raise ValueError('Invalid skin influences '+slot) if len(combined)>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 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) 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], pbr={'baseColorFactor':[*component['tint'],1.0],
'baseColorTexture':glb.texture(folder/(slot+'_base_color.png')), 'baseColorTexture':glb.texture(folder/(slot+'_base_color.png')),
'metallicFactor':0.65 if slot=='SkateTruck' else 0.0, 'metallicFactor':0.65 if slot=='SkateTruck' else 0.0,
@@ -113,7 +115,7 @@ def convert(models,private,recipe):
root=len(nodes);nodes.append({'name':'Skate3_RX2_Rig','children':root_children+[mesh_node]}) 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))), glb.doc.update(nodes=nodes,meshes=[{'primitives':primitives}],skins=[{'joints':list(range(len(indices))),
'inverseBindMatrices':inverse}],scenes=[{'nodes':[root]}],scene=0) 'inverseBindMatrices':inverse}],scenes=[{'nodes':[root]}],scene=0)
glb.save(private/'skater.glb') glb.save(output or private/'skater.glb')
if __name__=='__main__': if __name__=='__main__':
import argparse import argparse
@@ -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())
+6 -2
View File
@@ -333,7 +333,7 @@ def morph_weight(name: str, manifest: dict) -> float:
if name == "thin" or name.startswith("thin_"): if name == "thin" or name.startswith("thin_"):
return float(body["skinniness"]) return float(body["skinniness"])
if name in manifest["morph_assembly"]["face_targets"]: 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}") raise RuntimeError(f"Retail morph target has no recipe mapping: {name}")
def decode_dense_morphs( def decode_dense_morphs(
@@ -424,6 +424,7 @@ def decode_dense_morphs(
) )
deltas = [] deltas = []
normal_deltas = []
nonzero_vertices = 0 nonzero_vertices = 0
nonzero_normal_deltas = 0 nonzero_normal_deltas = 0
max_delta = 0.0 max_delta = 0.0
@@ -434,7 +435,9 @@ def decode_dense_morphs(
x, y, z, _remap = struct.unpack_from( x, y, z, _remap = struct.unpack_from(
">4h", data, vertex_offset ">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 nonzero_normal_deltas += 1
if be_u32(data, vertex_offset + 12) != 0x3F800000: if be_u32(data, vertex_offset + 12) != 0x3F800000:
raise RuntimeError( raise RuntimeError(
@@ -456,6 +459,7 @@ def decode_dense_morphs(
"hash": f"{target_hash:016x}", "hash": f"{target_hash:016x}",
"descriptor_index": descriptor_index, "descriptor_index": descriptor_index,
"deltas": deltas, "deltas": deltas,
"normal_deltas": normal_deltas,
"nonzero_vertices": nonzero_vertices, "nonzero_vertices": nonzero_vertices,
"nonzero_normal_deltas": nonzero_normal_deltas, "nonzero_normal_deltas": nonzero_normal_deltas,
"max_source_delta": max_delta, "max_source_delta": max_delta,
@@ -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()
+21
View File
@@ -0,0 +1,21 @@
@echo off
setlocal
cd /d "%~dp0"
if not exist "%~dp0skate3rust-customiser.exe" (
echo The dedicated customiser executable is missing.
pause
exit /b 1
)
echo Skate 3 Character Customiser Test
echo Open Escape / Start, then choose Character customiser.
echo Save: F5 / controller X. Back: Escape / controller B.
echo First-time clothing changes can take a few seconds.
echo Diagnostic output is written to customiser-session.log.
"%~dp0skate3rust-customiser.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-session.log" 2>&1
set "skate_customiser_exit=%errorlevel%"
if not "%skate_customiser_exit%"=="0" (
echo The test exited with code %skate_customiser_exit%.
echo See %~dp0customiser-session.log
pause
)
exit /b %skate_customiser_exit%