mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Simplify character menus and add resident live customisation
This commit is contained in:
1 parent
a2ead83471
commit
6e686edb77
19 files changed
+2688
-544
No files matched your search
@@ -29,6 +29,7 @@ fn bind(
|
||||
skins: Query<(Entity, &SkinnedMesh)>,
|
||||
nodes: Query<(&Name, &Transform)>,
|
||||
parents: Query<&ChildOf>,
|
||||
visibility: Query<&Visibility>,
|
||||
roots: Query<Entity, With<PlayerRoot>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
@@ -37,12 +38,16 @@ fn bind(
|
||||
}
|
||||
let names = &skater.animation.evaluator.frames.bone_names;
|
||||
let result = (|| -> Result<Option<Vec<BoneBinding>>, 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)) => {
|
||||
|
||||
@@ -66,12 +66,13 @@ fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
|
||||
if let Projection::Perspective(p) = &mut *projection {
|
||||
p.fov = previous.fov + (current.fov - previous.fov) * alpha;
|
||||
}
|
||||
if customiser.as_ref().is_some_and(|c| c.open) {
|
||||
let center = current.root.translation + Vec3::Y * 0.95;
|
||||
let offset = current.root.rotation * Vec3::new(0.0, 0.25, 3.2);
|
||||
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 * 0.85, Vec3::Y);
|
||||
*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;
|
||||
|
||||
+1171
-380
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
//! 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<StandardMaterial, SkinStamp>;
|
||||
|
||||
#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)]
|
||||
pub(crate) struct SkinStamp {
|
||||
#[texture(100)]
|
||||
#[sampler(101)]
|
||||
pub texture: Option<Handle<Image>>,
|
||||
/// 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 deferred_fragment_shader() -> ShaderRef {
|
||||
Self::fragment_shader()
|
||||
}
|
||||
}
|
||||
pub(crate) fn register(app: &mut App) {
|
||||
embedded_asset!(app, "customiser_stamp.wgsl");
|
||||
app.add_plugins(MaterialPlugin::<SkaterMaterial>::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<std::path::PathBuf>) {
|
||||
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::<Shader>::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);
|
||||
}
|
||||
let shader = Shader::from_wgsl(
|
||||
include_str!("customiser_stamp.wgsl"),
|
||||
"customiser_stamp.wgsl",
|
||||
);
|
||||
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_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));
|
||||
}
|
||||
cache.get(&(), 0, h.id(), &d).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
//! 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<String, String>,
|
||||
pub materials: Vec<String>,
|
||||
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<String, String>,
|
||||
diffuse: String,
|
||||
normal: Option<String>,
|
||||
rough: Option<String>,
|
||||
alpha: bool,
|
||||
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<String, Part>,
|
||||
pub materials: HashMap<String, Material>,
|
||||
pub defaults: HashMap<String, Value>,
|
||||
pub morphs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tattoos: HashMap<String, Tattoo>,
|
||||
#[serde(default)]
|
||||
pub colours: Vec<Colour>,
|
||||
}
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Parts {
|
||||
pub library: Library,
|
||||
geometry: HashMap<String, Handle<Gltf>>,
|
||||
materials: HashMap<String, (Handle<SkaterMaterial>, Vec<Handle<Image>>)>,
|
||||
instances: HashMap<String, Entity>,
|
||||
tattoos: HashMap<String, Handle<Image>>,
|
||||
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<Value, String> {
|
||||
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<String, Value>, 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<String, Value>,
|
||||
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<String> = 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<SkaterMaterial>) {
|
||||
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 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,
|
||||
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::default(),
|
||||
});
|
||||
self.materials.insert(id.into(), (material, images));
|
||||
}
|
||||
}
|
||||
pub(crate) fn setup(
|
||||
mut commands: Commands,
|
||||
config: Res<crate::config::Config>,
|
||||
server: Res<AssetServer>,
|
||||
) {
|
||||
let library = std::fs::read(config.asset_root.join("private/customisation/library.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&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<AssetServer>,
|
||||
mut parts: ResMut<Parts>,
|
||||
mut state: ResMut<crate::customiser::Customiser>,
|
||||
mut materials: ResMut<Assets<SkaterMaterial>>,
|
||||
root: Query<Entity, With<crate::world::PlayerRoot>>,
|
||||
parents: Query<&ChildOf>,
|
||||
mut scenes: Query<(Entity, Option<&PartRoot>, &mut Visibility), With<SceneRoot>>,
|
||||
mut mesh_materials: Query<(Entity, Option<&mut MeshMaterial3d<SkaterMaterial>>), With<Mesh3d>>,
|
||||
mut morphs: Query<(Entity, &mut MorphWeights)>,
|
||||
mut animation: ResMut<crate::animation::AnimationStatus>,
|
||||
) {
|
||||
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<String> = 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::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<f32> = 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
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::<MeshMaterial3d<StandardMaterial>>()
|
||||
.insert(MeshMaterial3d(handle.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let weights: Vec<f32> = 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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<f32>,
|
||||
rectangle: vec4<f32>,
|
||||
enabled: vec4<f32>,
|
||||
}
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var stamp_texture: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var stamp_sampler: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> 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<f32>,
|
||||
#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
|
||||
// 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<f32>(
|
||||
pbr_input.material.base_color.rgb * mix(vec3<f32>(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;
|
||||
}
|
||||
@@ -467,7 +467,7 @@ mod tests {
|
||||
.insert_resource(images)
|
||||
.insert_resource(Menu {
|
||||
open: false, selected: 0, settings: GraphicsSettings::default(),
|
||||
difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(),
|
||||
difficulty: Difficulty::Easy, maps: vec![], selected_map: 0, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(),
|
||||
})
|
||||
.add_systems(Update, apply);
|
||||
app.world_mut().spawn((Window::default(), PrimaryWindow));
|
||||
|
||||
@@ -16,6 +16,8 @@ mod verification;
|
||||
mod performance;
|
||||
mod graphics_menu;
|
||||
mod customiser;
|
||||
mod customiser_parts;
|
||||
mod customiser_material;
|
||||
mod render_capacity;
|
||||
mod retail_render;
|
||||
mod presentation;
|
||||
|
||||
@@ -169,6 +169,9 @@ 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);
|
||||
|
||||
@@ -859,6 +859,7 @@ mod tests {
|
||||
&mut Commands::new(&mut queue, &world),
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut Assets::<crate::retail_render::RetailWorldMaterial>::default(),
|
||||
&mut images,
|
||||
);
|
||||
queue.apply(&mut world);
|
||||
|
||||
@@ -62,6 +62,18 @@ pub(crate) struct SkaterAnimation {
|
||||
}
|
||||
|
||||
impl SkaterAnimation {
|
||||
/// 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())
|
||||
}
|
||||
|
||||
+93
-137
@@ -1,124 +1,115 @@
|
||||
# Character customisation test build
|
||||
# Character customiser V2
|
||||
|
||||
A dedicated local executable and `Play Character Customiser.bat` are now prepared.
|
||||
The game was **not launched**: validation here is compilation, synthetic unit tests,
|
||||
and offline retail asset assembly. User playtesting is still required.
|
||||
The dedicated local test build uses `Play Character Customiser V2.bat` and
|
||||
`skate3rust-customiser-v2.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.
|
||||
|
||||
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.
|
||||
## Menu and controls
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
`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.
|
||||
## Implemented controls
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
|
||||
## Implemented, private-data-only tooling
|
||||
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.
|
||||
|
||||
`tools/asset_pipeline/customisation_catalog.py` reads the user's existing
|
||||
`createacharacter.big` and `db.big` directly. It indexes the authored XML,
|
||||
exports CAC BIN/VLT data with complete arrays, and optionally extracts only
|
||||
explicitly selected models and their texture dependencies. It does not require
|
||||
an ISO dialog, map conversion, downloads, a running game, or Blender.
|
||||
## Immediate appearance updates
|
||||
|
||||
```powershell
|
||||
python -m tools.asset_pipeline.customisation_catalog `
|
||||
--game-root <owned-extracted-game> --output <private-task-staging>
|
||||
```
|
||||
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.
|
||||
|
||||
Outputs are private `catalog.json`, `native.json`, `extraction-report.json`,
|
||||
the source XML, and five small database inputs. Do not commit, bundle, or
|
||||
publish these files. The output directory must be separate from the owned
|
||||
source. Existing staged resources are reused only when their bytes match;
|
||||
different content is never overwritten. Publication of a resource is atomic.
|
||||
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.
|
||||
|
||||
`--selection <private.json>` accepts an array like the following, with IDs
|
||||
copied from that user's catalog rather than invented:
|
||||
## 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
|
||||
[{"slot": <component slot>, "asset_id": <model variant ID>, "lod": 0,
|
||||
"material_ids": [<one material ID for each ordered material instance>]}]
|
||||
python -m tools.asset_pipeline.customisation_library <private-worker.json>
|
||||
python -m tools.asset_pipeline.customisation_profiles <private-customisation-directory>
|
||||
```
|
||||
|
||||
This validates **extraction membership**, not outfit compatibility. A successful
|
||||
extraction must not be presented as a renderable or gameplay-ready character.
|
||||
The report explicitly sets `runtime_ready` to false. The existing installer
|
||||
still prepares only the fallback skater; this tool is an opt-in preparation
|
||||
boundary until the runtime dependencies are implemented.
|
||||
The old worker remains an offline compatibility tool. It is not the V2 runtime.
|
||||
|
||||
The catalog keeps original shader names, every shader parameter and texture
|
||||
channel in order, authoring flags including empty values, model names and
|
||||
IDs, both LODs, and ordered material instances. Unknown scalar/sampler/UV
|
||||
attributes are retained in `authored`; missing runtime constants are never
|
||||
filled with guessed values. The original XML bytes are also preserved.
|
||||
Validation: 21 Python tests and five 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.
|
||||
|
||||
The native export preserves reflected fields and inheritance links, including
|
||||
complete `MorphPreset`/`ColourPreset` arrays and relocated text-array strings.
|
||||
It resolves class/collection names using the archive's own summary report;
|
||||
it does not require an IDA dump or a personal hash-name cache. Its `morphs`
|
||||
index preserves target order and UI-zone index separately. The two native
|
||||
gender-branch parameter triples remain labelled A/B pending verification of
|
||||
the caller's boolean convention; both triples match in the inspected bank.
|
||||
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.
|
||||
|
||||
## Owned-data validation
|
||||
## Additional native evidence for V2
|
||||
|
||||
The inspected archive contains **19 component slots, 481 model variants,
|
||||
962 LOD records and 3,328 material definitions**. Every referenced model and
|
||||
texture exists in the same archive. These are authored counts, not counts of
|
||||
unlocked or compatible menu choices.
|
||||
|
||||
The female Rostral has two material instances at each LOD. The extractor
|
||||
requires both; it does not silently keep the first. The high-LOD mesh has
|
||||
1,432 vertices and 7,362 indices. How the two native material loops contribute
|
||||
to that mesh remains part of the compositor investigation.
|
||||
|
||||
The ten fallback components resolve to the exact model IDs, arena IDs,
|
||||
material IDs, texture bindings and resource SHA-256s in
|
||||
`tools/default_skater_retail_manifest.json`. Forty-four resources including
|
||||
the catalog XML were staged; a second run reused all forty-four unchanged.
|
||||
A separate female-head request staged both material-loop dependency sets.
|
||||
|
||||
The native export contains 267 CAC-related collections, 19 morph mappings,
|
||||
and 21 complete morph-preset arrays with 19 elements each (the schema default
|
||||
plus ten male and ten female presets). This is raw authored data; parent-row
|
||||
inheritance and preset application still need the native consumer.
|
||||
|
||||
Synthetic verification, requiring no game content:
|
||||
|
||||
```text
|
||||
python -m unittest tools.asset_pipeline.test_customisation_catalog tools.asset_pipeline.test_vlt
|
||||
```
|
||||
|
||||
Thirteen tests cover VLT array bounds/alignment/text relocation, material-loop
|
||||
ordering, broken references, missing resources, staging isolation, no-clobber
|
||||
reuse, separate UI/target indices and malformed morph ranges. No game,
|
||||
recomp, screenshot process, or `--check-assets` run was launched.
|
||||
- `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
|
||||
|
||||
@@ -167,38 +158,3 @@ Do not equate low-level constructors with gameplay defaults.
|
||||
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.
|
||||
|
||||
## Remaining implementation gates
|
||||
|
||||
1. **CAC material composition.** The current character converter exports
|
||||
`StandardMaterial`; its fallback image preparation explicitly skips
|
||||
`decal/decal2` because they are shader inputs, not unconditional overlays.
|
||||
It handles diffuse, hair alpha and normal reconstruction for that fallback.
|
||||
The native catalog includes skin/face stamps, cloth stamps, colour shifts,
|
||||
alpha and multi-instance cases. Recover the native compositor's input
|
||||
constants, masks, UV transforms, stamp ordering and tint math, then connect
|
||||
them to the renderer's character-material adapter. The renderer task
|
||||
confirmed this dependency is not implemented. Do not multiply arbitrary
|
||||
colours or paste tattoo textures into diffuse images as a substitute.
|
||||
2. **Assembly and rigs.** Finish the native correction sequence and validate
|
||||
both genders against authored component skinning/bind matrices. Preserve
|
||||
hair-under-hat variants, arm/leg substitutions, socks, layered inner/outer
|
||||
tops, removed necklaces/wrist items and multiple material instances. The
|
||||
test worker now supports male single-material mesh groups and applies the
|
||||
explicit garment/hair/removal flags, but full native sequence parity remains.
|
||||
3. **Complete profile/menu semantics.** Apply parent-row inheritance and
|
||||
native face presets, colour zones, available-item/unlock filters, tattoos,
|
||||
gestures, stance, style and posture. Keep physical board adjustments
|
||||
separate. Use actual saved defaults rather than constructor placeholders.
|
||||
4. **Runtime/UI validation.** The test implementation now includes navigation,
|
||||
preview/save/back, persistence and in-process replacement. It still requires
|
||||
user gameplay testing, particularly replacement while paused, reopening saved
|
||||
outfits and controller navigation. No game-based validation was run.
|
||||
|
||||
The requested hierarchy is recorded verbatim in `HIERARCHY`. It is a scope
|
||||
contract, not a declaration that those pages work. The map-switch task's
|
||||
proposed interface leaves a separate customiser resource and character
|
||||
entities alive, emits `WorldChanged` after `MapTransitionSet`, and reconstructs
|
||||
the skater using shared immutable animation data. Reapply saved profile values
|
||||
after that transition without moving selection ownership into transient
|
||||
physics state. This interface still needs integration against its final commit.
|
||||
@@ -32,9 +32,12 @@ class Glb:
|
||||
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)
|
||||
|
||||
def convert(models,private,recipe,*,stock=None,output=None,materials=None):
|
||||
def convert(models,private,recipe,*,stock=None,output=None,materials=None,live_morphs=False,geometry_only=False,reference_models=None):
|
||||
source=AnimSource((stock or private/'stock')/'data/anim/OnBoard.abin')
|
||||
skeleton=SkeletonSet(str(models))
|
||||
if reference_models:
|
||||
reference_skeleton=SkeletonSet(str(reference_models))
|
||||
skeleton.bind={**reference_skeleton.bind,**skeleton.bind}
|
||||
if skeleton.errors:raise ValueError(str(skeleton.errors))
|
||||
mapping=skeleton.index_map(ABIN.BONE_NAMES,source.parents)
|
||||
worlds={mapping[n]:m for n,m in skeleton.bind.items() if n in mapping}
|
||||
@@ -76,8 +79,15 @@ def convert(models,private,recipe,*,stock=None,output=None,materials=None):
|
||||
raw=raw[0];positions=np.asarray(mesh['pos'],dtype=np.float64)
|
||||
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)
|
||||
normals=np.asarray(raw['normals'],dtype=np.float64)
|
||||
for morph in morphs:
|
||||
if 'normals' in raw:
|
||||
normals=np.asarray(raw['normals'],dtype=np.float64)
|
||||
else:
|
||||
normals=np.zeros_like(positions)
|
||||
for triangle in mesh['tris']:
|
||||
a,b,c=triangle;normal=np.cross(positions[b]-positions[a],positions[c]-positions[a])
|
||||
for index in triangle:normals[index]+=normal
|
||||
normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20)
|
||||
for morph in ([] if live_morphs else morphs):
|
||||
weight=morph_weight(morph['name'],recipe)
|
||||
positions+=np.asarray(morph['deltas'])*weight
|
||||
if weight:
|
||||
@@ -93,28 +103,44 @@ def convert(models,private,recipe,*,stock=None,output=None,materials=None):
|
||||
weights/=weights.sum(axis=1,keepdims=True)
|
||||
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)
|
||||
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__':
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""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:
|
||||
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,
|
||||
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/'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()))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,78 @@
|
||||
"""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:'<f4', 5123:'<u2', 5125:'<u4'}[accessor['componentType']],
|
||||
count=accessor['count']*width, offset=view.get('byteOffset',0)+accessor.get('byteOffset',0))
|
||||
self.assertTrue(np.isfinite(result).all())
|
||||
return result.reshape(-1,width)
|
||||
|
||||
for mesh in doc['meshes']:
|
||||
self.assertEqual(mesh['extras']['targetNames'], library['morphs'])
|
||||
self.assertEqual(mesh['weights'], [0.,0.]+[.25]*17+[0.]*3)
|
||||
for primitive in mesh['primitives']:
|
||||
a = primitive['attributes']; count = len(array(a['POSITION']))
|
||||
self.assertLess(int(array(primitive['indices']).max()), count)
|
||||
self.assertLess(int(array(a['JOINTS_0']).max()), len(doc['skins'][0]['joints']))
|
||||
np.testing.assert_allclose(array(a['WEIGHTS_0']).sum(1), 1., atol=1e-6)
|
||||
self.assertEqual(len(primitive['targets']),22)
|
||||
for target in primitive['targets']:
|
||||
self.assertEqual(len(array(target['POSITION'])),count)
|
||||
self.assertEqual(len(array(target['NORMAL'])),count)
|
||||
if (part['slot']=='Arm' and part['flags'].get('ArmModelType')=='shoulder') or (part['slot']=='Leg' and part['flags'].get('LegModelType') in ['thigh','thighdown'] and part['flags'].get('RequiresSockStyle','none') in ['','none']):
|
||||
uv = array(a['TEXCOORD_1'])
|
||||
for side in ['Q3','Q4']:
|
||||
top,bottom,left,right = map(float,part['flags']['StampUVConstraint'+side].split(','))
|
||||
inside=(uv[:,0]>=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']:
|
||||
if material[channel]: self.assertTrue((assets/material[channel]).is_file())
|
||||
for tattoo in library['tattoos'].values():
|
||||
self.assertTrue((assets/tattoo['texture']).is_file())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,21 +1,26 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
if not exist "%~dp0skate3rust-customiser.exe" (
|
||||
echo The dedicated customiser executable is missing.
|
||||
if not exist "%~dp0skate3rust-customiser-v2.exe" (
|
||||
echo The V2 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
|
||||
if not exist "%~dp0assets\private\customisation\library.json" (
|
||||
echo The prepared character library is missing.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Skate 3 Character Customiser V2
|
||||
echo Open Escape / Start, then Character customiser.
|
||||
echo Browse with arrows / D-pad. Change with left-right or the minus-plus buttons.
|
||||
echo Type to search an item list. Escape / B goes back. Done saves and resumes.
|
||||
echo Diagnostic output: customiser-v2-session.log
|
||||
"%~dp0skate3rust-customiser-v2.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-v2-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
|
||||
echo See %~dp0customiser-v2-session.log
|
||||
pause
|
||||
)
|
||||
exit /b %skate_customiser_exit%
|
||||
+5
@@ -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.
|
||||
+4
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user