Simplify character menus and add resident live customisation

This commit is contained in:
chazmm committed 2026-09-08 17:08:34 +01:00
1 parent a2ead83471
commit 6e686edb77
19 files changed
+2688 -544

No files matched your search

+8 -6
View File
@@ -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)) => {
+5 -4
View File
@@ -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;
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();
}
}
}
+772
View File
@@ -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"));
}
}
+129
View File
@@ -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;
}
+1 -1
View File
@@ -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));
+2
View File
@@ -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;
+3
View File
@@ -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);
+1
View File
@@ -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);
+12
View File
@@ -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
View File
@@ -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 -6
View File
@@ -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()
+14 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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)