Merge character customisation with map and teleport lifecycle

This commit is contained in:
chazmm committed 2026-09-08 17:54:55 +01:00
commit 26f056cf34
32 files changed
+4406 -38

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)) => {
+1 -1
View File
@@ -106,6 +106,6 @@ pub(crate) fn build(
verification::VerificationPlugin,
crate::performance::PerformancePlugin,
));
app.add_plugins(crate::session_marker::SessionMarkerPlugin);
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
app
}
+10
View File
@@ -52,6 +52,7 @@ fn spawn(mut commands: Commands, config: Res<Config>, retail: Res<crate::retail_
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
replay: Res<crate::replay::Replay>,
customiser: Option<Res<crate::customiser::Customiser>>,
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
if let Ok(window) = windows.single() {
runtime.set_aspect_ratio(window.width() / window.height());
@@ -65,6 +66,15 @@ 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 let Some(customiser) = customiser.as_ref().filter(|c| c.open) {
let (height,distance,yaw)=customiser.preview_camera();
let center = current.root.translation + Vec3::Y * height;
let offset = current.root.rotation * Quat::from_rotation_y(yaw) * Vec3::new(0.0, 0.25, distance);
let eye = center + offset;
let right = offset.normalize().cross(Vec3::Y).normalize();
*transform = Transform::from_translation(eye).looking_at(center + right * (distance * 0.265625), Vec3::Y);
if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); }
}
camera.is_active = true;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,182 @@
//! Skin stamps modify albedo before the unchanged StandardMaterial lighting.
use bevy::{
asset::embedded_asset,
pbr::{ExtendedMaterial, MaterialExtension},
prelude::*,
render::render_resource::AsBindGroup,
shader::ShaderRef,
};
pub(crate) type SkaterMaterial = ExtendedMaterial<StandardMaterial, SkinStamp>;
#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)]
pub(crate) struct SkinStamp {
#[texture(100)]
#[sampler(101)]
pub texture: Option<Handle<Image>>,
/// cac_hair_defaultPS samples opacity.r on TEXCOORD1, independently of diffuse alpha.
#[texture(103)]
#[sampler(104)]
pub hair_opacity: 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 prepass_fragment_shader() -> ShaderRef {
"embedded://skate3rust/customiser_prepass.wgsl".into()
}
fn deferred_fragment_shader() -> ShaderRef {
Self::fragment_shader()
}
}
pub(crate) fn register(app: &mut App) {
embedded_asset!(app, "customiser_stamp.wgsl");
embedded_asset!(app, "customiser_prepass.wgsl");
app.add_plugins(MaterialPlugin::<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);
}
for (source, path, prepass) in [
(
include_str!("customiser_stamp.wgsl"),
"customiser_stamp.wgsl",
false,
),
(
include_str!("customiser_prepass.wgsl"),
"customiser_prepass.wgsl",
true,
),
] {
let shader = Shader::from_wgsl(source, path);
let h = assets.add(shader.clone());
cache.set_shader(h.id(), shader);
for secondary_uv in [false, true] {
let mut d = defs.clone();
for name in [
"VERTEX_UVS",
"MAY_DISCARD",
"VERTEX_UVS_A",
"VERTEX_OUTPUT_INSTANCE_INDEX",
"VERTEX_NORMALS",
"MESH_PIPELINE",
"STANDARD_MATERIAL",
"STANDARD_MATERIAL_BASE_COLOR_TEXTURE",
] {
d.push(ShaderDefVal::Bool(name.into(), true));
}
if secondary_uv {
d.push(ShaderDefVal::Bool("VERTEX_UVS_B".into(), true));
}
if prepass {
d.push(ShaderDefVal::Bool("PREPASS_PIPELINE".into(), true));
}
cache
.get(&(), 0, h.id(), &d)
.unwrap_or_else(|e| panic!("{path} secondary UV {secondary_uv}: {e:?}"));
if prepass {
for name in [
"PREPASS_FRAGMENT",
"NORMAL_PREPASS",
"NORMAL_PREPASS_OR_DEFERRED_PREPASS",
] {
d.push(ShaderDefVal::Bool(name.into(), true));
}
cache
.get(&(), 0, h.id(), &d)
.unwrap_or_else(|e| panic!("Normal {path} UV {secondary_uv}: {e:?}"));
}
}
}
}
}
+805
View File
@@ -0,0 +1,805 @@
//! Resident retail parts: selection changes visibility/materials; morphs stay on GPU.
use crate::customiser_material::{SkaterMaterial, SkinStamp};
use bevy::{gltf::Gltf, mesh::morph::MorphWeights, prelude::*};
use serde::Deserialize;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
#[derive(Clone, Deserialize)]
pub(crate) struct Part {
pub slot: String,
pub name: String,
pub flags: HashMap<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,
#[serde(default)]
opacity: Option<String>,
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 opacity = m.opacity.as_ref().map(|p| load(p, true));
let material = materials.add(SkaterMaterial {
base: StandardMaterial {
base_color: Color::srgb(m.tint[0], m.tint[1], m.tint[2]),
base_color_texture: Some(diffuse),
normal_map_texture: normal,
metallic_roughness_texture: rough,
double_sided: m.opacity.is_some(),
cull_mode: if m.opacity.is_some() {
None
} else {
Some(bevy::render::render_resource::Face::Back)
},
metallic: m.metallic,
perceptual_roughness: if m.rough.is_some() { 1. } else { m.roughness },
alpha_mode: if m.alpha {
AlphaMode::Mask(0.5)
} else {
AlphaMode::Opaque
},
..default()
},
extension: SkinStamp {
hair_opacity: opacity,
enabled: if m.opacity.is_some() {
Vec4::Y
} else {
Vec4::ZERO
},
..default()
},
});
self.materials.insert(id.into(), (material, images));
}
}
pub(crate) fn setup(
mut commands: Commands,
config: Res<crate::config::Config>,
server: Res<AssetServer>,
) {
let library = std::fs::read(
config
.asset_root
.join("private/customisation/library-v3.json"),
)
.or_else(|_| std::fs::read(config.asset_root.join("private/customisation/library.json")))
.ok()
.and_then(|b| serde_json::from_slice::<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 {
hair_opacity: materials
.get(&handle)
.and_then(|m| m.extension.hair_opacity.clone()),
enabled: if source.opacity.is_some() {
Vec4::Y
} else {
Vec4::ZERO
},
..default()
};
if let Some((tid, t)) = selected {
let key = match stamp["side"].as_u64().unwrap_or(0) {
1 => "StampUVConstraintQ4",
2 => "StampUVConstraintQ1",
3 => "StampUVConstraintQ2",
_ => "StampUVConstraintQ3",
};
let bounds: Vec<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,
..default()
};
}
}
let changed = materials.get(&handle).is_some_and(|m| {
m.extension.texture != extension.texture
|| m.extension.transform != extension.transform
|| m.extension.enabled != extension.enabled
});
if changed {
if let Some(m) = materials.get_mut(&handle) {
m.extension = extension;
}
}
for (e, material) in &mut mesh_materials {
if !parents.iter_ancestors(e).any(|p| p == entity) {
continue;
}
if let Some(mut material) = material {
if material.0 != handle {
material.0 = handle.clone();
}
} else {
commands
.entity(e)
.remove::<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,172 @@
// Bevy 0.18.1 pbr_prepass.wgsl, MIT/Apache-2.0, copyright Bevy contributors.
// Match native hair coverage in colour, depth and shadow passes.
struct SkinStamp {
transform: vec4<f32>,
rectangle: vec4<f32>,
enabled: vec4<f32>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var hair_opacity: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> stamp: SkinStamp;
#import bevy_pbr::{
pbr_prepass_functions,
pbr_bindings,
pbr_bindings::material,
pbr_types,
pbr_functions,
pbr_functions::SampleBias,
prepass_io,
mesh_bindings::mesh,
mesh_view_bindings::view,
}
#import bevy_render::bindless::{bindless_samplers_filtering, bindless_textures_2d}
#ifdef MESHLET_MESH_MATERIAL_PASS
#import bevy_pbr::meshlet_visibility_buffer_resolve::resolve_vertex_output
#endif
#ifdef BINDLESS
#import bevy_pbr::pbr_bindings::material_indices
#endif // BINDLESS
fn customiser_alpha_discard(in: prepass_io::VertexOutput) {
#ifdef VERTEX_UVS_B
let alpha = textureSample(hair_opacity, hair_sampler, in.uv_b).r;
if stamp.enabled.y > 0.5 {
let coverage = pbr_functions::alpha_discard(pbr_bindings::material, vec4<f32>(1.0, 1.0, 1.0, alpha));
return;
}
#endif
pbr_prepass_functions::prepass_alpha_discard(in);
}
#ifdef PREPASS_FRAGMENT
@fragment
fn fragment(
#ifdef MESHLET_MESH_MATERIAL_PASS
@builtin(position) frag_coord: vec4<f32>,
#else
in: prepass_io::VertexOutput,
@builtin(front_facing) is_front: bool,
#endif
) -> prepass_io::FragmentOutput {
#ifdef MESHLET_MESH_MATERIAL_PASS
let in = resolve_vertex_output(frag_coord);
let is_front = true;
#else // MESHLET_MESH_MATERIAL_PASS
#ifdef BINDLESS
let slot = mesh[in.instance_index].material_and_lightmap_bind_group_slot & 0xffffu;
let flags = pbr_bindings::material_array[material_indices[slot].material].flags;
let uv_transform = pbr_bindings::material_array[material_indices[slot].material].uv_transform;
#else // BINDLESS
let flags = pbr_bindings::material.flags;
let uv_transform = pbr_bindings::material.uv_transform;
#endif // BINDLESS
// If we're in the crossfade section of a visibility range, conditionally
// discard the fragment according to the visibility pattern.
#ifdef VISIBILITY_RANGE_DITHER
pbr_functions::visibility_range_dither(in.position, in.visibility_range_dither);
#endif // VISIBILITY_RANGE_DITHER
customiser_alpha_discard(in);
#endif // MESHLET_MESH_MATERIAL_PASS
var out: prepass_io::FragmentOutput;
#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION
out.frag_depth = in.unclipped_depth;
#endif // UNCLIPPED_DEPTH_ORTHO_EMULATION
#ifdef NORMAL_PREPASS
// NOTE: Unlit bit not set means == 0 is true, so the true case is if lit
if (flags & pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u {
let double_sided = (flags & pbr_types::STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT) != 0u;
let world_normal = pbr_functions::prepare_world_normal(
in.world_normal,
double_sided,
is_front,
);
var normal = world_normal;
#ifdef VERTEX_UVS
#ifdef VERTEX_TANGENTS
#ifdef STANDARD_MATERIAL_NORMAL_MAP
// TODO: Transforming UVs mean we need to apply derivative chain rule for meshlet mesh material pass
#ifdef STANDARD_MATERIAL_NORMAL_MAP_UV_B
let uv = (uv_transform * vec3(in.uv_b, 1.0)).xy;
#else
let uv = (uv_transform * vec3(in.uv, 1.0)).xy;
#endif
// Fill in the sample bias so we can sample from textures.
var bias: SampleBias;
#ifdef MESHLET_MESH_MATERIAL_PASS
bias.ddx_uv = in.ddx_uv;
bias.ddy_uv = in.ddy_uv;
#else // MESHLET_MESH_MATERIAL_PASS
bias.mip_bias = view.mip_bias;
#endif // MESHLET_MESH_MATERIAL_PASS
let Nt =
#ifdef MESHLET_MESH_MATERIAL_PASS
textureSampleGrad(
#else // MESHLET_MESH_MATERIAL_PASS
textureSampleBias(
#endif // MESHLET_MESH_MATERIAL_PASS
#ifdef BINDLESS
bindless_textures_2d[material_indices[slot].normal_map_texture],
bindless_samplers_filtering[material_indices[slot].normal_map_sampler],
#else // BINDLESS
pbr_bindings::normal_map_texture,
pbr_bindings::normal_map_sampler,
#endif // BINDLESS
uv,
#ifdef MESHLET_MESH_MATERIAL_PASS
bias.ddx_uv,
bias.ddy_uv,
#else // MESHLET_MESH_MATERIAL_PASS
bias.mip_bias,
#endif // MESHLET_MESH_MATERIAL_PASS
).rgb;
let TBN = pbr_functions::calculate_tbn_mikktspace(normal, in.world_tangent);
normal = pbr_functions::apply_normal_mapping(
flags,
TBN,
double_sided,
is_front,
Nt,
);
#endif // STANDARD_MATERIAL_NORMAL_MAP
#endif // VERTEX_TANGENTS
#endif // VERTEX_UVS
out.normal = vec4(normal * 0.5 + vec3(0.5), 1.0);
} else {
out.normal = vec4(in.world_normal * 0.5 + vec3(0.5), 1.0);
}
#endif // NORMAL_PREPASS
#ifdef MOTION_VECTOR_PREPASS
#ifdef MESHLET_MESH_MATERIAL_PASS
out.motion_vector = in.motion_vector;
#else
out.motion_vector = pbr_prepass_functions::calculate_motion_vector(in.world_position, in.previous_world_position);
#endif
#endif
return out;
}
#else
@fragment
fn fragment(in: prepass_io::VertexOutput) {
customiser_alpha_discard(in);
}
#endif // PREPASS_FRAGMENT
+135
View File
@@ -0,0 +1,135 @@
// StandardMaterial entry point; the added block only composes skin-stamp albedo.
// Bevy 0.18.1 pbr.wgsl, MIT/Apache-2.0, copyright Bevy contributors.
struct SkinStamp {
transform: vec4<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(103) var hair_opacity: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_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
// The diffuse alpha is NOT coverage. Hair has a separate red-channel
// opacity map on secondary UVs (cac_hair_defaultVS/PS).
let hair_alpha = textureSample(hair_opacity, hair_sampler, in.uv_b).r;
pbr_input.material.base_color.a = mix(pbr_input.material.base_color.a, hair_alpha, stamp.enabled.y);
// cacstamp_skin_defaultPS: skin * (1 + (decal.rgb - 1) * decal.a).
// Its VS transforms TEXCOORD1 by the two i_customGraphic rows.
let stamp_uv = in.uv_b * stamp.transform.xy + stamp.transform.zw;
let decal = textureSample(stamp_texture, stamp_sampler, stamp_uv);
let inside = all(in.uv_b >= stamp.rectangle.xy) && all(in.uv_b <= stamp.rectangle.zw);
let opacity = decal.a * stamp.enabled.x * select(0.0, 1.0, inside);
pbr_input.material.base_color = vec4<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;
}
+19 -13
View File
@@ -75,7 +75,7 @@ impl GraphicsSettings {
}
#[derive(Resource)]
pub(crate) struct Menu {
open: bool,
pub(crate) open: bool,
selected: usize,
settings: GraphicsSettings,
path: PathBuf,
@@ -101,7 +101,7 @@ struct FramePacer(Instant);
#[derive(Component)]
struct MenuRoot;
#[derive(Component)]
struct MenuRow(usize);
pub(crate) struct MenuRow(usize);
#[derive(Component)]
struct MenuLabel(usize);
#[derive(Component)]
@@ -209,7 +209,7 @@ fn setup(
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
for i in 0..10 {
for i in 0..11 {
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
@@ -247,9 +247,11 @@ fn cycle<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32;
values[(index + direction).rem_euclid(values.len() as i32) as usize]
}
fn interact(
pub(crate) fn interact(
mut config: ResMut<crate::config::Config>,
mut transition: ResMut<crate::map_transition::MapTransition>,
mut customiser: ResMut<crate::customiser::Customiser>,
nav: Res<crate::customiser::Navigation>,
mut physics: ResMut<crate::physics::GamePhysics>,
keys: Res<ButtonInput<KeyCode>>,
mut menu: ResMut<Menu>,
@@ -262,21 +264,22 @@ fn interact(
time.pause();
return;
}
if keys.just_pressed(KeyCode::Escape) {
if customiser.open { return; }
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
menu.open = !menu.open;
}
let mut action = None;
if menu.open {
if keys.just_pressed(KeyCode::ArrowUp) {
menu.selected = (menu.selected + 9) % 10;
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + 10) % 11;
}
if keys.just_pressed(KeyCode::ArrowDown) {
menu.selected = (menu.selected + 1) % 10;
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
menu.selected = (menu.selected + 1) % 11;
}
if keys.just_pressed(KeyCode::ArrowLeft) {
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some((menu.selected, -1));
}
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) {
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 {
action = Some((menu.selected, 1));
}
for (interaction, row) in &buttons {
@@ -325,6 +328,7 @@ fn interact(
9 => {
exit.write(AppExit::Success);
}
10 => customiser.begin(),
_ => {}
}
if row < 5 {
@@ -399,13 +403,14 @@ fn labels(
menu: Res<Menu>,
transition: Res<crate::map_transition::MapTransition>,
time: Res<Time<Real>>,
customiser: Res<crate::customiser::Customiser>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>,
) {
root.display = if menu.open {
root.display = if menu.open && !customiser.open {
Display::Flex
} else {
Display::None
@@ -443,7 +448,8 @@ fn labels(
6 => format!("Map {}", menu.maps[menu.selected_map].label),
7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() },
8 => "Resume".into(),
_ => "Quit game".into(),
9 => "Quit game".into(),
_ => "Character customiser".into(),
};
}
***status = if transition.busy() {
+1 -1
View File
@@ -7,7 +7,7 @@ pub(crate) mod gesture_catalog;
mod gesture_mapping_data;
pub(crate) mod gesture_mapping;
pub(crate) mod gesture_input;
mod platform;
pub(crate) mod platform;
pub(crate) use controllers::{ControllerInput, ControllerStatus};
use skate_core::input::tick::TickInput;
+2 -2
View File
@@ -2,7 +2,7 @@
//! converter without Bevy/gilrs deadzones or normalized-axis reconstruction.
use skate_core::input::xbox::XboxState;
pub(super) struct DevicePacket {
pub(crate) struct DevicePacket {
pub number: u32,
pub state: XboxState,
pub subtype: u8,
@@ -95,7 +95,7 @@ mod windows {
}
}
pub(super) fn poll(index: usize) -> Result<DevicePacket, DeviceError> {
pub(crate) fn poll(index: usize) -> Result<DevicePacket, DeviceError> {
assert!(index < 4);
#[cfg(windows)]
return windows::poll(index as u32);
+3
View File
@@ -18,6 +18,9 @@ mod skater_animation;
mod verification;
mod performance;
mod graphics_menu;
mod customiser;
mod customiser_parts;
mod customiser_material;
mod render_capacity;
mod retail_render;
mod presentation;
+9
View File
@@ -170,6 +170,15 @@ mod exchange_tests {
}
impl GamePhysics {
pub(crate) fn set_gesture_preferences(&mut self, gestures: Option<[u32; 4]>) {
self.animation_profile.gesture_selections = gestures.filter(|g| g.iter().all(|v| *v < 37));
}
pub(crate) fn set_equipment_preferences(&mut self, truck: f32, wheel: f32) {
if truck.is_finite() && wheel.is_finite() {
self.animation_profile.truck_tightness = truck.clamp(0.0, 1.0);
self.animation_profile.wheel_hardness = wheel.clamp(0.0, 1.0);
}
}
pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) {
// Actor publication carries this selector into the next physical packet.
// Keep the board, active trick, equipment preferences and controller history.
@@ -293,3 +293,21 @@ mod tests {
super::super::GroundState::load(&data, "default", true).unwrap();
}
}
#[cfg(test)]
#[test]
#[ignore = "requires extracted private stock collections"]
fn customiser_truck_tightness_changes_authored_steering() {
use skate_core::riding::steering::{SteeringInput, calculate_tilt};
let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap());
let data = Collections::load(&root).unwrap();
let settings = GroundSettings::load(&data, "default", "default").unwrap();
let samples: Vec<_> = [0.0, 0.7, 1.0].into_iter().map(|tightness| calculate_tilt(
&settings.steering,
SteeringInput { turn: 0.6, absolute_body_speed: 5.0, flipped_controls_scalar: 1.0,
truck_tightness: tightness, ..Default::default() }, None, None,
)).collect();
assert!(samples[0].abs() > samples[2].abs());
assert!((samples[2] / samples[0] - settings.steering.tight_trucks_scalar).abs() < 0.00001);
eprintln!("Authored truck tightness tilt samples: {samples:?}; tight scalar {}", settings.steering.tight_trucks_scalar);
}
+12
View File
@@ -97,6 +97,18 @@ impl SkaterAnimation {
self.request_checkpoint_stance(u32::from(forward));
}
/// Native Initialize82B97E38 selects both orientation/mirror bits for
/// regular stance. A profile edit changes the natural basis while retaining
/// the current relative stance and trick state.
pub(crate) fn set_customisation(&mut self, natural: u32, style: u32) {
if natural <= 1 && self.state.publication.natural_stance != natural as i32 {
self.state.publication.natural_stance = natural as i32;
self.state.flags ^= 0xC000_0000;
}
// GetCACSettings82590D20..D84: 0=null,1=Loose,2=Gonzo,3=Aggressive.
let name: &[u8] = match style { 1 => b"Loose", 2 => b"Gonzo", 3 => b"Aggressive", _ => b"" };
self.motion.playback_context.pro_skater = encode(name);
}
pub fn stance(&self) -> (bool, bool) {
(self.state.fakie(), self.state.mirrored())
}
+52 -2
View File
@@ -356,7 +356,7 @@ fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig()
#[test]
#[ignore = "requires private stock skater, animation banks and collections"]
fn stock_wheel_hardness_reaches_ground_force_and_torque() {
fn customiser_equipment_reaches_ground_force_and_torque() {
let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT");
let root = std::path::Path::new(&root);
let manifest = skate_data::GameAssets::load(root).unwrap();
@@ -365,6 +365,10 @@ fn stock_wheel_hardness_reaches_ground_force_and_torque() {
for hardness in [0.0_f32, 0.7, 1.0] {
let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap();
let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap();
crate::customiser::apply_preferences(
&serde_json::json!({"truck": hardness, "wheel": hardness}),
&mut physics, &mut skater.animation,
);
let mut controls = PlayerControls::default();
let mut camera = crate::camera::CameraRuntime::load(root).unwrap();
let input = crate::input::ControllerInput::default();
@@ -382,7 +386,8 @@ fn stock_wheel_hardness_reaches_ground_force_and_torque() {
// Replay identical observed sideways travel at the Ground input boundary.
// Only the published profile input changes; run the actual Ground adapter.
let p = &mut skater.player_input.processed;
p.scalar_2764 = hardness;
assert_eq!(p.scalar_2764, hardness);
assert_eq!(p.truck_tightness_2760, hardness);
p.vectors_400_416 = [[1.0_f32, 0.0, 3.0, 0.0].map(f32::to_bits); 2];
p.scalar_2656 = 10.0_f32.sqrt();
skater.ground.state.elapsed_2648 = 0.25;
@@ -454,3 +459,48 @@ fn stock_geometry_loads_and_reaches_the_live_board_solver() {
let deck = physics.board.part_transforms()[BodyId::Deck.index()];
assert!(deck.translation.y > ground::HEIGHT);
}
#[test]
#[ignore = "requires private stock animation banks and collections; no window or renderer"]
fn customiser_styles_and_postures_change_stock_pose() {
use skate_core::animation::playback::{PlayAnimation, PlayAnimationInstance, TransitionSettings};
use skate_core::animation::playback_tree::{Evaluation, PoseCommand};
let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap());
let manifest = skate_data::GameAssets::load(&root).unwrap();
let graphs = crate::graph_runtime::StockGraphs::load(&root, &manifest).unwrap();
let mut physics = GamePhysics::load(&root).unwrap();
let mut skater = SkaterRuntime::load(&root, &graphs, &physics, "normal").unwrap();
let operation = PlayAnimation {
animation: "S_R_PUSHLSP_HSTR_N_0_CYC1".into(), switch_animation: None, mirror_animation: None,
no_board_animation: None, playback_speed: 1.0, apply_posture: true,
transition: TransitionSettings { kind: 1, seconds: 0.0, under: 0, matching: 0, use_channels_from_weights: false },
parameters: vec![],
};
let mut samples = Vec::new();
for (style, posture) in [(0,0), (1,0), (2,0), (3,0), (0,1), (0,2), (0,3)] {
crate::customiser::apply_preferences(
&serde_json::json!({"style":style, "posture":posture}),
&mut physics, &mut skater.animation,
);
let motion = &mut skater.animation.motion;
PlayAnimationInstance::default().begin(&operation, &mut motion.playback_context, &mut motion.animation).unwrap();
motion.animation.apply_parameters().unwrap();
motion.animation.seek_current_fraction(0.35);
let commands = motion.animation.evaluate_pose(Evaluation { cull_threshold: 0.0001, update_history: false }).unwrap();
let pose = skater.animation.evaluator.evaluate(&commands).unwrap();
let names: Vec<_> = commands.iter().filter_map(|c| match c {
PoseCommand::Clip { name, .. } | PoseCommand::Pose { name } => Some(name.clone()), _ => None,
}).collect();
eprintln!("Style {style}, posture {posture}: {names:?}");
if let Some(baseline) = samples.first() {
assert!(&pose != baseline, "Style {style}, posture {posture} did not change the evaluated stock pose");
}
samples.push(pose);
}
skater.animation.set_customisation(1,0);
let stance = skater.animation.stance();
skater.animation.set_customisation(0,0);
assert_ne!(skater.animation.stance(), stance);
skater.animation.set_customisation(1,0);
assert_eq!(skater.animation.stance(), stance);
}
+223
View File
@@ -0,0 +1,223 @@
# Character customiser V4
The dedicated local test build uses `Play Character Customiser V4.bat` and
`skate3rust-customiser-v4.exe`. Open Escape/Start, then Character customiser.
The game and launcher have not been executed for validation; the build is ready
for user playtesting. No existing game executable is replaced.
## Hair coverage and gameplay verification (V3)
Hair now uses its independent opacity texture's **red channel on TEXCOORD1**.
The owned `cac_hair_defaultVS` copies primary/secondary UVs into XY/ZW, and
`cac_hair_defaultPS` samples diffuse RGB from XY and opacity R from ZW. Output
alpha is opacity R times `i_params.x`. The old combined diffuse-alpha texture
was incorrect: on the moussed-up example only 0.29% of diffuse pixels exceed the
0.5 cutoff, versus 42.18% of the opacity red channel. That discarded most raised
hair geometry. All 31 opacity-bearing hair materials now retain separate maps.
Thin hair cards render from both sides. The colour and depth/shadow passes use
the same corrected coverage; existing PBR lighting formulas remain unchanged.
V3 uses `library-v3.json` when present, with a legacy `library.json` fallback.
For side-by-side preparation set `"library_index":"library-v3.json"` in the
private preparation config. V2's index, executable and launcher are retained.
Offline checks use the same `apply_preferences` function called by the menu,
then the real stock actor publication, processed physics input and consumers:
- Truck tightness reaches processed field 2760. At identical steering input,
loose/default/tight produce tilt 0.08955247 / 0.07074646 / 0.062686734. The
tight/loose ratio is the loaded native `tight_trucks_scalar`, 0.7.
- Wheel hardness reaches processed field 2764. At identical sideways travel,
soft/default/hard produce side force -23.31247 / -15.153105 / -11.656235.
Ratios match `physicswheels/default/SoftestWheelSideFrictionScalar` from the
extracted collection data. Straightening torque also changes, from -1.113991
to 1.1697853 at the endpoints. These are controlled test samples, not universal
steering angles, grip percentages or speed ratings.
- Standard/Loose/Gonzo/Aggressive select distinct stock push clips through the
`PROSKATER` selector: default, Hsu, Gonzales and PJ respectively. Evaluated bone
poses differ. Stiff/Slouch/Buff add their respective authored posture poses
and change the evaluated result. Switching natural stance changes its basis
and switching back restores it.
The stock riding idle is shared between styles. Style differences appear in
specific authored movements (including pushes and selected tricks), and posture
is applied on eligible motion-tree construction. Wheel/truck merchandise models
are appearance choices; **Wheel hardness** and **Truck tightness** are the native
handling controls. No invented brand-dependent statistics are added.
Shader validation covers colour, depth/shadow and normal prepass paths, each
with and without secondary UVs. Tests run without a game window or GPU. The hair
fix still needs the user's visual playtest; no game process was launched here.
## Menu and controls
Right stick left/right rotates the character preview through 360 degrees. A
24% dead zone prevents stick drift; rotation accelerates with stick deflection
up to 2 radians/second. It uses real frame time while gameplay is paused and
keeps the existing automatic close-up framing. The viewing angle resets when
opening the customiser and is not saved into the character profile. V4 retains
the V3 hair and gameplay changes and uses the same prepared library.
Four main sections: **Body, Clothes, Board, Style**. Item lists are alphabetical,
fit the available panel height, and can be filtered by typing. Backspace edits a
search; Escape/B clears it before going back. Arrows/D-pad/left stick browse,
left/right or visible minus/plus buttons adjust, and Enter/A chooses. Mouse wheel
and page buttons move through lists. Back returns one level. **Done**, present
only at the root, writes the profile and resumes skating. There are no repeated
Save buttons or material submenus.
Names remove exporter terms and distinguish new/worn finishes. Board graphics,
trucks and wheels are individually named entries instead of hundreds of hidden
variants behind one model. The camera frames the selected area, and turns around
for back tattoos. Tattoo previews temporarily use the authored viewing outfit;
this does not change the saved clothes.
## Implemented controls
- Both bodies use their authored fallback outfits and retail rigs.
- Seven native skin colours, native hair colours, hairstyles and facial hair.
- Body weight/definition and all 17 individual facial controls, with native
`0..0.5` ranges and unchanged defaults.
- Ten native face presets per body, decoded by target hash and normalized range.
- Upper/lower tattoos: 170 owned designs; left/right limbs plus chest/back
placement for upper-body stamps. Tattoos can be removed and are saved.
- Hats, T-shirts, shirts, hoodies, jackets, sweaters, pants/shorts, shoes, socks,
accessories, decks, trucks and wheels. Authored variants and primary clothing
colours are selectable. Hat/hair, sleeves, inner tops, legs/socks and accessory
removal dependencies are resolved together.
- All 37 gestures in each of the four direction slots, regular/goofy stance,
Standard/Loose/Gonzo/Aggressive skating styles, and native posture options.
- Truck tightness and wheel hardness retain `0..1`, step `0.1`, default `0.7`.
Physical/animation preferences apply to the running actor. Posture and skating
style are consumed by the appropriate native motion construction when skating
resumes; selecting them does not fabricate a separate preview animation.
## Immediate appearance updates
The Python converter is no longer invoked by the game. Preparation creates 480
resident part GLBs and shared textures. Visible choices also prefetch their
resolved sleeve, leg, hair and skin dependencies. Selection changes visibility
and material handles as one transaction after dependencies are ready. Morph
weights update directly on the GPU. Existing parts stay visible during initial
asset loading. First-time GPU/texture loading can still take frames; no measured
zero-latency or frame-rate claim is made without gameplay testing.
Each visible rig is bound to the existing stock pose. Shared joint entities are
bound once. The untouched original character remains in use until the customiser
is opened or a valid saved profile is found. The profile lives at
`settings/character.json` beside the test assets and is written through a temporary
file and rename. Legacy profiles without a gender field are interpreted as male.
Map restarts reuse the same asset/settings root and reload the saved profile.
## Asset preparation and validation
Retail payloads, catalogues, converted parts and machine paths remain private.
The source archive and installed stock assets are reused; no downloads, Blender,
ISO prompts, or map reconversion are needed. The non-geometric Misc model is the
stamp catalogue, not a missing renderable part. The female face's extra XML
material group is retained in metadata; its high-LOD source has one drawable mesh.
Preparation commands, using a private configuration with the existing source and
asset roots:
```text
python -m tools.asset_pipeline.customisation_library <private-worker.json>
python -m tools.asset_pipeline.customisation_profiles <private-customisation-directory>
```
The old worker remains an offline compatibility tool. It is not the runtime.
Validation: 21 Python tests and eight Rust customiser tests pass. Owned-data checks
cover all 480 GLBs, indices, skin weights, 22 GPU target slots, texture references,
both default outfits, all selectable model/material combinations against those
outfits, presets, search, colour selection, JSON round trips, and temporary tattoo
outfits. Shader composition is parsed and validated offline with Bevy's shader
cache, with and without secondary UVs; this creates no window or GPU device.
Release compilation also passes. Interactive layout, controller feel and visual
appearance still require the user's game test.
This is a sandbox catalogue, with owned items available irrespective of career
unlock state. The existing StandardMaterial lighting remains in use. Full retail
lighting/multipass material parity is outside this UI/profile update; primary
clothing tint is not a claim of every native colour-zone effect.
## Additional native evidence for V2
- `GetCACSettings`, `0x82590B50`, including its complete tail: regular/goofy,
four gesture indices, posture, and style selectors Standard/Loose/Gonzo/Aggressive.
- `ResetGestureSet`, `0x824FA730`: marks all 37 gesture entries available and
initializes the first four in table order.
- `InitializeSkaterAnim`, `0x82B97E38`: stance basis and high flag bits. Changing
natural stance preserves the actor's relative stance.
- `cac_presets`: 32-byte MorphPreset records, target hash at byte 8, normalized
float at byte 24; invert `0x8253CD18` normalization using native target ranges.
- `cacstamp_skin_defaultVS`: transforms TEXCOORD1 with the two i_customGraphic
rows. `cacstamp_skin_defaultPS`: multiplies skin albedo by
`1 + (decal.rgb - 1) * decal.a`, before lighting. The V2 extension adds this
composition and retains Bevy's existing PBR/post-lighting entry point.
- RX2 format `0x002C23A5` is Xenos format 37, FLOAT2, not SHORT4N. The secondary
UV decoder now reads two big-endian float32 values. Retail rectangle order is
top/bottom/left/right; conversion to downward texture V is corroborated by
artwork pixel bounds and exposed-limb UV regions. Left limb positions also
agree with the authored LeftArm bind positions.
## Native evidence
Addresses below are virtual addresses in the inspected image whose dump base
is `0x82000000`; subtract that base for a dump-relative offset. Symbol names
from the prior Ghidra export are leads, corroborated with instructions and
authored data. The original IDA database was opened read-only; symbol import
and exploratory disassembly used a private copy.
Source identities:
- Owned `default.xex` SHA-256:
`1db39496585c521d17a2137804f42cf73ebed2b32cac166ec42dbf772f4dcf7f`.
- TU3 raw image SHA-256:
`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`.
- CAC XML SHA-256:
`be2f23ab6d2f29b5ac5e143133788e72e1bebd073adedb343effd1fd7e94f4f1`.
- `skatercollections.vlt` SHA-256:
`3b7dbd062bb1c906a085514355afff35cfa22f486ae70820c5ad1a42a7aab25b`.
- The character archive identity is recorded in the existing fallback manifest.
Observed native behaviors:
| Consumer | Evidence and implication |
| --- | --- |
| `FrontEndState_CaC::ReceiveInput`, `0x825FC7E8` | Cases 47/48 adjust recipe fields `+0x2004/+0x2008` by signed `0.1`, clamped to `[0,1]`. Constants: `0x820641A8=0.1`, `0x82165A10=0`, `0x8231A844=1`, `0x8216DEE0=-1`. These are truck tightness/wheel hardness preferences, distinct from merchandise IDs. |
| `GetIntegerValue`, `0x825FFB18` | Cases 47/48 read those same floats and convert to display ticks with the scale at `0x821963E4=10` and rounding offset `0x8209975C=0.5`. |
| `CASMorphParams` initialization, `0x8253CA60` | Uses the `cac_morph_params` layout's target order at `+4`, target prefix at `+0`, and parameter triples at `+12/+16/+20` or `+24/+28/+32`. Each target's working record is 28 bytes. |
| Morph normalization, `0x8253CD18` | Clamps to that target's minimum/maximum then computes `(value-min)/(max-min)`. The UI's normalized value is not the mesh's direct delta weight. |
| `GetTargetOrder`, `0x8253CF50` | Matches target-name prefixes against 19 entries; clothing `fat_*`/`thin_*` must follow the corresponding native mapping. |
| `CASPlayerInitializer::InitializePlayer`, `0x8253D470` | Copies each working record's default at `+8` into the 19-value customisation container; chooses default parts through `CACPartDB`. |
| `CASAssembler::Correct`, `0x8253F648` | Coordinates hair, rostral, torso, organ, legs/socks, other accessories, removals, colours and stamp information. XML flags are inputs to this sequence, not sufficient proof of a replacement assembly algorithm. |
| `CorrectRemovals`, `0x8253FD40` | Walks the native slots, resolves component names in comma-separated removal strings, and clears the referenced slots. |
| `CorrectRostral`, `0x825406E0`; `CorrectTorso`, `0x82540828` | Further select material/part combinations from native colour and clothing relationships. |
| `GetCACSettings`, `0x82590B50` | Reads saved stance, four gestures and posture-related profile fields. Existing Rust posture consumers already preserve the pending-tree application semantics. |
Observed `cac_morph_params` values in this bank are `min=0`, `max=0.5` for
all 19 targets. Face defaults are `0.25`; fat and thin defaults are zero.
Target order is **fat=0, thin=1**. The existing fallback parser's descriptive
`skinniness/fatness` names must not be used to infer that ordering merely
because both default values happen to be zero. Nose height/length have swapped
target-order and UI-zone indices; the export retains both.
Do not equate low-level constructors with gameplay defaults.
`RecipeData` constructor `0x82DDE700` initializes equipment values to zero,
while the current Rust actor profile uses `0.7`, following its documented
actor settings path. That current behavior is unchanged here. Saved/default
CAC profile initialization must be traced before changing it.
## Integration with map switching and checkpoint recovery
The customiser plugin coexists with the in-process map menu and session-marker
HUD. Controller navigation is sampled before menu input. Profile preferences
are applied after map-transition publication and before gameplay input, so a
fresh skater receives the saved stance, style and equipment settings before its
first physical tick. Character entities and the resident library remain alive
across map changes. Native checkpoint stance requests remain deferred through
the teleport graph; profile edits do not replace that recovery path.
The combined release compiles. Runtime/visual checks remain user-run.
+38 -10
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):
source=AnimSource(private/'stock/data/anim/OnBoard.abin')
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,11 +79,20 @@ def convert(models,private,recipe):
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)
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)
if weight and morph['nonzero_normal_deltas']:raise ValueError('Unsupported morph normals '+slot)
positions+=np.asarray(morph['deltas'])*weight
normals=np.asarray(raw['normals'],dtype=np.float64);normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20)
if weight:
normals+=np.asarray(morph['normal_deltas'])*weight
normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20)
joints=np.zeros((len(positions),4),dtype=np.uint16);weights=np.zeros((len(positions),4),dtype=np.float32)
if not mesh['skin']:raise ValueError('Missing skin '+slot)
for i,influences in enumerate(mesh['skin']):
@@ -89,31 +101,47 @@ def convert(models,private,recipe):
if len(combined)>4 or not combined:raise ValueError('Invalid skin influences '+slot)
for j,(bone,w) in enumerate(combined.items()):joints[i,j]=joint_index[bone];weights[i,j]=w
weights/=weights.sum(axis=1,keepdims=True)
folder=private/'default_skater/textures/materials'
folder=materials or private/'default_skater/textures/materials'
pbr={'baseColorFactor':[*component['tint'],1.0],
'baseColorTexture':glb.texture(folder/(slot+'_base_color.png')),
'metallicFactor':0.65 if slot=='SkateTruck' else 0.0,
'roughnessFactor':0.48 if slot in {'SkateTruck','SkateWheel'} else 0.72}
if not geometry_only:pbr['baseColorTexture']=glb.texture(folder/(slot+'_base_color.png'))
rough=folder/(slot+'_roughness.png')
if rough.is_file():
if not geometry_only and rough.is_file():
channel=Image.open(rough).convert('RGB').getchannel('R')
white=Image.new('L',channel.size,255);packed=Image.merge('RGB',(white,channel,white));buffer=io.BytesIO();packed.save(buffer,format='PNG')
pbr['metallicRoughnessTexture']=glb.texture(buffer.getvalue());pbr['roughnessFactor']=1.0
material={'name':'Retail_'+slot,'pbrMetallicRoughness':pbr,'doubleSided':False,
'alphaMode':component.get('alpha_mode','OPAQUE')}
if material['alphaMode']=='MASK':material['alphaCutoff']=component.get('alpha_cutoff',0.5)
if component['textures'].get('normal'):material['normalTexture']=glb.texture(folder/(slot+'_normal.png'))
if not geometry_only and component['textures'].get('normal'):material['normalTexture']=glb.texture(folder/(slot+'_normal.png'))
glb.doc['materials'].append(material)
attributes={'POSITION':glb.accessor(positions,'VEC3',bounds=True),'NORMAL':glb.accessor(normals,'VEC3'),
'TEXCOORD_0':glb.accessor(raw['uvs'],'VEC2'),'JOINTS_0':glb.accessor(joints,'VEC4',5123),
'WEIGHTS_0':glb.accessor(weights,'VEC4')}
if live_morphs and 'uvs2' in raw:
attributes['TEXCOORD_1']=glb.accessor(raw['uvs2'],'VEC2')
primitives.append({'attributes':attributes,'indices':glb.accessor(np.asarray(mesh['tris']).ravel(),'SCALAR',5125),
'material':len(glb.doc['materials'])-1})
if live_morphs:
names_morph=recipe['morph_assembly'].get('live_targets',['fat','thin']+recipe['morph_assembly']['face_targets'])
zero=glb.accessor(np.zeros_like(positions),'VEC3',bounds=True)
targets={}
for morph in morphs:
name=morph['name']
key='fat' if name.startswith('fat') else 'thin' if name.startswith('thin') else name
if key not in names_morph:raise ValueError('Unmapped live morph '+name)
targets[key]={'POSITION':glb.accessor(morph['deltas'],'VEC3',bounds=True),
'NORMAL':glb.accessor(morph['normal_deltas'],'VEC3')}
primitives[-1]['targets']=[targets.get(n,{'POSITION':zero,'NORMAL':zero}) for n in names_morph]
mesh_node=len(nodes);nodes.append({'name':'Skate3_SkaterAndBoard','mesh':0,'skin':0})
root=len(nodes);nodes.append({'name':'Skate3_RX2_Rig','children':root_children+[mesh_node]})
glb.doc.update(nodes=nodes,meshes=[{'primitives':primitives}],skins=[{'joints':list(range(len(indices))),
'inverseBindMatrices':inverse}],scenes=[{'nodes':[root]}],scene=0)
glb.save(private/'skater.glb')
if live_morphs:
glb.doc['meshes'][0].update(weights=[.25 if n in recipe['morph_assembly']['face_targets'] else 0. for n in names_morph],
extras={'targetNames':names_morph})
glb.save(output or private/'skater.glb')
if __name__=='__main__':
import argparse
@@ -0,0 +1,295 @@
"""Index owned CAC authoring data; extract only explicitly selected resources.
This is a lossless metadata boundary, not a character assembler or a material
compositor. It deliberately does not equate an authored variant with a usable
in-game option. Gender, unlock, layering, material-loop and stamp rules still
have to be applied by the native CAC implementation.
Run with ``python -m tools.asset_pipeline.customisation_catalog --help``.
Never distribute its output: the catalog is derived from the user's disc.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import xml.etree.ElementTree as ET
from tools.owned_game.big import BigArchive
CATALOG_PATH = "data/content/recipe/createacharacter/cas_db_xfd1ca62f_000.xml"
MODEL_PREFIX = "data/content/createacharacter/model/cas_db"
TEXTURE_PREFIX = "data/content/createacharacter/texture"
# Navigation labels requested by the user. This is a scope contract, not a list
# of implemented pages. Do not generate working controls from this list alone.
HIERARCHY = [
{"name": "Body mods", "children": [
"Gender", "Skin", "Hair", "Body shape", "Facial presets",
"Face modification", "Facial hair", "Upper body tattoo", "Lower body tattoo",
]},
{"name": "Merchandise", "children": [
"Hats", "Tshirts", "Button shirts", "Hoodies", "Jackets", "Sweaters",
"Pants", "Shoes", "Socks", "Accessories", "Skateboards",
]},
{"name": "Edit style", "children": ["Gestures", "Stance", "Style", "Posture"]},
{"name": "Trucks adjustment", "children": []},
{"name": "Wheels adjustment", "children": []},
]
def asset_id(value: str) -> str:
if not re.fullmatch(r"(?:0x)?[0-9a-fA-F]{16}", value):
raise ValueError(f"Invalid retail asset ID: {value!r}")
return value.removeprefix("0x").lower()
def authored(node: ET.Element) -> dict:
"""Keep ordering, attributes, and scalar strings; never invent shader data."""
result = {"tag": node.tag, "attributes": dict(node.attrib)}
if node.text and node.text.strip():
result["text"] = node.text
if len(node):
result["children"] = [authored(child) for child in node]
return result
def authoring_flags(node: ET.Element) -> dict[str, str]:
result = {}
for group in node.findall("ud"):
if group.get("n") != "cas":
continue
for field in group:
if field.tag in result:
raise ValueError(f"Duplicate CAC field {field.tag}")
result[field.tag] = field.get("value", "")
return result
def parse_catalog(xml: bytes, archive_paths: set[str]) -> dict:
root = ET.fromstring(xml)
if (root.tag, root.get("n"), root.get("type"), root.get("schemaversion")) != (
"compositeasset", "cas_db", "CreateACharacter", "6.0"
):
raise ValueError("Unsupported CAC authoring schema")
if any(child.tag not in {"mat", "comp"} for child in root):
raise ValueError("Unrecognized CAC root record")
available = {p.lower() for p in archive_paths}
materials = {}
for material in root.findall("mat"):
key = asset_id(material.attrib["id"])
if key in materials:
raise ValueError(f"Duplicate material {key}")
textures = []
for parameter in material.findall("sp"):
# Keep duplicate channels and their order rather than silently
# collapsing separate authored bindings into a channel dictionary.
texture = asset_id(parameter.attrib["id"])
path = f"{TEXTURE_PREFIX}/0x{texture}.rx2"
textures.append({"channel": parameter.attrib["chn"], "id": texture,
"path": path, "in_archive": path.lower() in available})
materials[key] = {
"shader": material.attrib["type"], "textures": textures,
"flags": authoring_flags(material), "authored": authored(material),
}
components = []
slots = set()
for component in root.findall("comp"):
slot = component.attrib["n"]
if not re.fullmatch(r"[A-Za-z]+", slot) or slot in slots:
raise ValueError(f"Invalid or duplicate component slot {slot!r}")
slots.add(slot)
if any(child.tag != "mod" for child in component):
raise ValueError(f"Unrecognized record in {slot}")
models = []
ids = set()
for model in component.findall("mod"):
key = asset_id(model.attrib["id"])
if key in ids:
raise ValueError(f"Duplicate model {slot}/{key}")
ids.add(key)
lods = []
lod_indices = set()
for lod in model.findall("lod"):
index = int(lod.attrib["idx"])
if index < 0 or index in lod_indices:
raise ValueError(f"Invalid or duplicate LOD {slot}/{key}/{index}")
lod_indices.add(index)
# XML arenaid names the RX2 FILE; XML id is the arena asset ID.
# The fallback .recipe stores them in the opposite field order.
model_id = asset_id(lod.attrib["arenaid"])
path = f"{MODEL_PREFIX}/{slot}/0x{model_id}.rx2"
instances = []
for instance in lod.findall("matinst"):
variants = []
variant_ids = set()
for variant in instance.findall("matvar"):
material_id = asset_id(variant.attrib["id"])
if material_id not in materials:
raise ValueError(f"Missing material definition {material_id}")
if material_id in variant_ids:
raise ValueError(f"Duplicate material variant {slot}/{key}/{material_id}")
variant_ids.add(material_id)
variants.append({"id": material_id, "name": variant.attrib["n"],
"authored": authored(variant)})
if not variants:
raise ValueError(f"Empty material instance {slot}/{key}")
instances.append(variants)
if not instances:
raise ValueError(f"Missing material instances {slot}/{key}")
lods.append({"index": index, "arena_asset_id": asset_id(lod.attrib["id"]),
"model_id": model_id, "path": path,
"in_archive": path.lower() in available,
"material_instances": instances})
if not lods:
raise ValueError(f"Missing LODs {slot}/{key}")
models.append({"id": key, "name": model.attrib["n"],
"flags": authoring_flags(model), "lods": lods,
"authored": authored(model)})
components.append({"slot": slot, "models": models})
return {"version": 1, "source": {"entry": CATALOG_PATH,
"xml_sha256": hashlib.sha256(xml).hexdigest(), "attributes": dict(root.attrib)},
"scope": HIERARCHY, "materials": materials, "components": components}
def selected_paths(catalog: dict, selections: list[dict]) -> list[str]:
"""Validate extraction requests, not gender/outfit/material compatibility.
Each request supplies slot, asset_id, lod and one material_id per ordered
material instance. Requiring every instance prevents losing female head's
second material loop. Requests do not authorize arbitrary archive paths.
"""
paths = set()
slots = set()
for selection in selections:
slot = selection["slot"]
if slot in slots:
raise ValueError(f"Repeated selected slot {slot}")
slots.add(slot)
candidates = [m for c in catalog["components"] if c["slot"] == slot
for m in c["models"] if m["id"] == asset_id(selection["asset_id"])]
if len(candidates) != 1:
raise ValueError(f"Unknown selected component {slot}/{selection['asset_id']}")
lods = [lod for lod in candidates[0]["lods"] if lod["index"] == selection["lod"]]
if len(lods) != 1:
raise ValueError(f"Unknown selected LOD for {slot}")
lod = lods[0]
if not lod["in_archive"]:
raise ValueError(f"Selected model is absent from archive: {lod['path']}")
choices = selection["material_ids"]
if len(choices) != len(lod["material_instances"]):
raise ValueError(f"{slot} requires {len(lod['material_instances'])} material selections")
paths.add(lod["path"])
for choice, instance in zip(choices, lod["material_instances"]):
key = asset_id(choice)
if key not in {v["id"] for v in instance}:
raise ValueError(f"Material {key} is not a variant of this {slot} instance")
for texture in catalog["materials"][key]["textures"]:
if not texture["in_archive"]:
raise ValueError(f"Selected texture is absent from archive: {texture['path']}")
paths.add(texture["path"])
return sorted(paths)
def write_private(path: Path, data: bytes) -> str:
"""Idempotent, no-clobber staging, including failed or interrupted runs."""
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
if path.read_bytes() != data:
raise ValueError(f"Refusing to replace different staged content: {path}")
return "reused"
# Publish a complete file without replacing another extractor's output.
# Windows rename is atomic and refuses an existing destination. POSIX
# rename replaces it, so use an exclusive hard link there instead.
descriptor, temporary = tempfile.mkstemp(prefix=".cac-", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
try:
if os.name == "nt":
os.rename(temporary, path)
else:
os.link(temporary, path)
except FileExistsError:
if path.read_bytes() != data:
raise ValueError(f"Conflicting concurrent staged content: {path}")
return "reused"
finally:
Path(temporary).unlink(missing_ok=True)
return "written"
def prepare(game_root: Path, output: Path, selections: list[dict] | None = None) -> dict:
game_root = game_root.resolve(strict=True)
output = output.resolve()
if output == game_root or output.is_relative_to(game_root) or game_root.is_relative_to(output):
raise ValueError("The private staging directory must be separate from the owned source")
archive = BigArchive(game_root / "data/content/createacharacter.big")
entries = {e.path.lower(): e for e in archive.entries}
if len(entries) != len(archive.entries):
raise ValueError("Ambiguous case-insensitive paths in CAC archive")
xml = archive.read(entries[CATALOG_PATH.lower()])
catalog = parse_catalog(xml, set(entries))
# Resolve every selected dependency before writing any selected resources.
paths = selected_paths(catalog, selections or [])
from tools.asset_pipeline.customisation_native import read_database
native = read_database(game_root, output / "database")
paths.append(CATALOG_PATH)
records = []
for name in paths:
entry = entries[name.lower()]
data = xml if name == CATALOG_PATH else archive.read(entry)
destination = (output / "source" / name).resolve()
if not destination.is_relative_to(output):
raise ValueError("Staged asset escaped the private directory")
state = write_private(destination, data)
records.append({"path": name, "size": len(data), "sha256": hashlib.sha256(data).hexdigest(),
"state": state})
models = [m for c in catalog["components"] for m in c["models"]]
report = {"version": 1, "source_xml_sha256": catalog["source"]["xml_sha256"],
"component_slots": len(catalog["components"]), "model_variants": len(models),
"materials": len(catalog["materials"]),
"native_collections": len(native["collections"]),
"native_morphs": len(native["morphs"]),
"multiple_material_instance_lods": sum(len(lod["material_instances"]) > 1
for m in models for lod in m["lods"]),
"missing_model_paths": sorted({lod["path"] for m in models for lod in m["lods"]
if not lod["in_archive"]}),
"missing_texture_paths": sorted({t["path"] for m in catalog["materials"].values()
for t in m["textures"] if not t["in_archive"]}),
"extracted": records,
"runtime_ready": False,
"requires": ["native CAC assembly and rig validation", "native material and tattoo composition",
"native recipe colour/morph/style defaults and menu filtering"]}
# Catalog output is deterministic. A different disc/version needs its own
# staging directory rather than silently reusing another catalog's files.
write_private(output / "catalog.json", (json.dumps(catalog, indent=2) + "\n").encode())
write_private(output / "native.json", (json.dumps(native, indent=2) + "\n").encode())
return report
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--game-root", required=True, type=Path, help="Owned extracted Xbox game directory")
parser.add_argument("--output", required=True, type=Path, help="Private task staging directory, outside game source")
parser.add_argument("--selection", type=Path, help="JSON array of explicit component extraction requests")
args = parser.parse_args()
selections = json.loads(args.selection.read_text(encoding="utf-8")) if args.selection else None
if selections is not None and not isinstance(selections, list):
parser.error("--selection must contain a JSON array")
report = prepare(args.game_root, args.output, selections)
report_path = (args.output / "extraction-report.json").resolve()
if not report_path.is_relative_to(args.output.resolve()):
raise ValueError("Report path escaped the private staging directory")
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"CAC_CATALOG_READY slots={report['component_slots']} models={report['model_variants']} "
f"materials={report['materials']} staged={len(report['extracted'])} runtime_ready=false")
if __name__ == "__main__":
main()
@@ -0,0 +1,156 @@
"""Prepare resident CAC geometry and shared textures outside the game loop."""
from pathlib import Path
import copy,json,shutil,sys,re,struct
import numpy as np
from PIL import Image,ImageChops
from tools.asset_pipeline.customisation_worker import ROOT,default_profile,flag
from tools.asset_pipeline.customisation_catalog import write_private
from tools.owned_game.big import BigArchive
def friendly(name):
text=name.lower().replace('_',' ')
replacements=[('shortsleeve','short sleeve'),('longsleeve','long sleeve'),('tshirt','tee'),
('t-shirt','tee'),('buttonup','button-up'),('zipup','zip-up'),('flippedup','flipped up'),
('kneedown',''),('thighdown',''),('medhem',''),('lowhem',''),('highhem',''),
('sleeves pushed up','rolled sleeves'),('sleeves up','rolled sleeves'),('sleevup','rolled sleeves'),
('colourizable',''),('colorizable',''),('stampable',''),('logoable',''),('material',''),
('lambert1','original'),('newera','new era'),('45right','angled right'),('45left','angled left'),
('long sleeve tee','long-sleeve tee'),('short sleeve tee','tee'),
('short sleeve button-up shirt','short-sleeve shirt'),('long sleeve button-up shirt','long-sleeve shirt'),
('shirt ls button-up','long-sleeve shirt'),('top button only','top button'),
('long sleeve mens shirt vneck','long-sleeve v-neck'),('jeans straight leg baggy waistline','baggy straight jeans'),
('jeans straight leg baggy waistline','baggy straight jeans'),('sunglasses','shades'),('necklaces',''),('tattoo',''),('biglizard','big lizard'),
('blueflame','blue flame'),('radialsun','sunburst'),('tribalbody','tribal'),('pinstripe','pinstripe ')]
for a,b in replacements:text=text.replace(a,b)
text=re.sub(r'\b(male\d*|female\d*|unisex|mesh|mat|hmat|h|l|high|low|ropa|hem|inner)\b','',text)
text=re.sub(r'\b(?:0x)?[a-f0-9]{8,}\b','',text)
text=re.sub(r'([a-z])([0-9]+)\b',r'\1 \2',text)
text=re.sub(r'\s+',' ',text).strip(' -')
text=text.replace('jeans straight leg baggy waistline','baggy straight jeans').replace('long-sleeve tee rolled sleeves','rolled-sleeve tee')
return text.title() or 'Original'
def variant_label(slot, model_name, native_name):
label=friendly(native_name)
worn=bool(re.search(r'(?:_| )d(?:_| |$)|dirty',native_name.lower()))
label=re.sub(r'\b(Diffuse|Tga|D|Malemat|Skateboard|Featured Artist|Instance)\b','',label)
label=re.sub(r'\b0\b','',label)
if slot=='Feet': return 'Worn' if worn else 'New'
model_words=set(friendly(model_name).lower().split())
words=[w for w in label.split() if w.lower() not in model_words]
label=' '.join(words).strip()
for a,b in [('Ozzie Wright','Ozzie'),('Grass Roots Pullover Hoodie','Grass Roots'),
('Full Back New Era',''),('Hoodie Hooddown Pullover',''),('Hood Up',''),
('Hooddown',''),('Closed',''),('Open',''),('In4Mation Colab Zip','In4Mation'),
('Nightmare Catcher','Nightmare'),('Gonz Animal Bullfish','Bullfish')]: label=label.replace(a,b)
label=re.sub(r'\s+',' ',label).strip()
if not label: label='Original'
return label+(' (Worn)' if worn else '')
def prepare(config):
from tools.extract_default_skater import import_rx2_parser,decode_texture,parse_fallback_recipe
from tools.asset_pipeline.retail_character import RX2,decode_dense_morphs
from tools.asset_pipeline.character_glb import convert
assets=Path(config['assets']);cache=assets/'private/customisation';out=cache/'library';out.mkdir(exist_ok=True)
catalog=json.loads((cache/'catalog.json').read_text());archive=BigArchive(Path(config['game_root'])/'data/content/createacharacter.big')
entries={e.path.lower():e for e in archive.entries}
def extract(path):
dest=cache/'source'/path
if not dest.exists():write_private(dest,archive.read(entries[path.lower()]))
return dest
parser=import_rx2_parser(ROOT/'tools/vendor/utt');decoded=cache/'decoded';decoded.mkdir(exist_ok=True)
def texture(tid,kind='raw'):
src=decoded/(tid+'.png')
if not src.exists():decode_texture(parser,extract('data/content/createacharacter/texture/0x'+tid+'.rx2'),src)
if kind=='raw':return src.relative_to(assets).as_posix()
dest=out/'textures'/(tid+'_'+kind+'.png');dest.parent.mkdir(exist_ok=True)
if not dest.exists():
image=Image.open(src).convert('RGBA');a=np.array(image,dtype=np.float64)
if kind=='normal':
x=a[:,:,3]/127.5-1.;y=a[:,:,1]/127.5-1.;scale=np.maximum(np.sqrt(x*x+y*y),1.);x/=scale;y/=scale
z=np.sqrt(np.maximum(0.,1.-x*x-y*y));image=Image.fromarray(np.rint((np.stack((x,y,z),axis=2)*.5+.5)*255.).astype('uint8'))
elif kind=='rough':
grey=ImageChops.invert(image.convert('L'));white=Image.new('L',image.size,255);image=Image.merge('RGB',(white,grey,white))
image.save(dest)
return dest.relative_to(assets).as_posix()
model_data={};mat_data={};errors=[]
recipe_base=json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())
recipe_base['morph_assembly']['live_targets']=['fat','thin']+recipe_base['morph_assembly']['face_targets']+['global_ethnicity_african','global_ethnicity_asian','global_ethnicity_caucasian']
reference=out/'reference'
for component in recipe_base['components']:
slot=component['slot'];model=next(m for p in catalog['components'] if p['slot']==slot for m in p['models'] if m['id']==component['asset_id'])
src=extract(next(l for l in model['lods'] if l['index']==0)['path']);dest=reference/slot/src.name
dest.parent.mkdir(parents=True,exist_ok=True)
if not dest.exists():shutil.copyfile(src,dest)
defaults={x['material_id']:x for x in recipe_base['components']}
for part in catalog['components']:
for model in part['models']:
if part['slot']=='Misc':continue # Non-geometric stamp catalogue, indexed below.
slot=part['slot'];lod=next(l for l in model['lods'] if l['index']==0);key=model['id'];path=out/(key+'_v4.glb')
try:
if not path.exists():
source=extract(lod['path']);folder=out/'work'/key/'models'/slot;folder.mkdir(parents=True,exist_ok=True)
mesh_file=folder/source.name
if not mesh_file.exists():shutil.copyfile(source,mesh_file)
parsed=RX2.parse_rx2(str(mesh_file));mesh=next(m for m in parsed['meshes'] if m.get('positions') and m.get('indices'))
morphs=decode_dense_morphs(mesh_file,parsed,len(mesh['positions']),RX2)
recipe=copy.deepcopy(recipe_base);recipe['components']=[dict(slot=slot,textures={},tint=[1.,1.,1.])]
recipe['morph_assembly']['expected_targets']={slot:[m['name'] for m in morphs]}
convert(folder.parent,assets/'private',recipe,output=path,geometry_only=True,live_morphs=True,reference_models=reference)
groups=[[v['id'] for v in group] for group in lod['material_instances']]
model_data[key]=dict(slot=slot,name=friendly(model['name']),flags={k[4:]:v for k,v in model['flags'].items() if v},
materials=groups[0],groups=groups,scene=path.relative_to(assets).as_posix())
for v in lod['material_instances'][0]:
mid=v['id']
if mid in mat_data:continue
mat=catalog['materials'][mid];tex={t['channel']:t['id'] for t in mat['textures']}
if 'diffuse' not in tex:continue
diffuse=texture(tex['diffuse'])
if 'alpha' in tex and slot!='Hair':
alpha_path=assets/texture(tex['alpha']);combined=out/'textures'/(tex['diffuse']+'_'+tex['alpha']+'.png')
if not combined.exists():
im=Image.open(assets/diffuse).convert('RGBA');alpha=Image.open(alpha_path).convert('RGBA')
alpha=alpha.resize(im.size,Image.Resampling.LANCZOS);mask=alpha.getchannel('A')
if mask.getextrema()==(255,255):mask=alpha.convert('L')
im.putalpha(ImageChops.multiply(im.getchannel('A'),mask));im.save(combined)
diffuse=combined.relative_to(assets).as_posix()
mat_data[mid]=dict(name=variant_label(slot,model['name'],v['name']),flags={k[4:]:val for k,val in mat['flags'].items() if val},
diffuse=diffuse,normal=texture(tex['normal'],'normal') if 'normal' in tex else None,
rough=texture(tex['specular'],'rough') if 'specular' in tex else None,
opacity=texture(tex['alpha']) if slot=='Hair' and 'alpha' in tex else None,
alpha='alpha' in tex,tint=defaults.get(mid,{}).get('tint',
[0.72,0.57,0.49] if mat['flags'].get('cas.SkinTone')=='light' else
[0.33,0.26,0.23] if mat['flags'].get('cas.SkinTone')=='dark' else
[0.02,0.01,0.01] if slot=='Hair' else [1.,1.,1.]),
metallic=.65 if slot=='SkateTruck' else 0.,roughness=.48 if slot in {'SkateTruck','SkateWheel'} else .72)
except Exception as e:errors.append(dict(model=key,slot=slot,error=str(e)))
print('Prepared',part['slot'],len(model_data),'models',len(mat_data),'materials',flush=True)
tattoos={}
misc=next(p for p in catalog['components'] if p['slot']=='Misc')
for variant in misc['models'][0]['lods'][0]['material_instances'][0]:
mid=variant['id'];mat=catalog['materials'][mid]
if not mat['flags'].get('cas.TattooCategory'):continue
tex={t['channel']:t['id'] for t in mat['textures']}
tattoos[mid]=dict(name=friendly(variant['name']),texture=texture(tex['decal']),
bounds=[float(v) for v in mat['flags']['cas.StampBorderConstraint'].split(',')])
male=default_profile();male['gender']='male'
female=copy.deepcopy(male);female['gender']='female';female['selections']={}
raw=parse_fallback_recipe(assets/'private/stock/data/cacrecipes/SavedRecipeFallbackFemale.bin',16)
for slot,items in raw['asset_lists'].items():
if items:
item=items[0];female['selections'][slot]={'asset_id':item['asset_id'],'material_id':item['models'][0]['material_id']}
colours=[]
native=json.loads((cache/'native.json').read_text())
for row in native['collections']:
if row['class']=='cac_colour' and row['key'].endswith('_clothing') and row['key']!='default_clothing':
colours.append(dict(name=row['key'][:-9].replace('_',' ').title(),
rgb=struct.unpack('>4f',bytes.fromhex(row['fields']['Hash_7827ED970A88B70C']['data']))[:3]))
colours.sort(key=lambda c:c['name'])
library=dict(version=3,colours=colours,tattoos=tattoos,models=model_data,materials=mat_data,defaults={'male':male,'female':female},
morphs=recipe_base['morph_assembly']['live_targets'],errors=errors)
(cache/config.get('library_index','library.json')).write_text(json.dumps(library,separators=(',',':')))
print('LIBRARY_READY',len(model_data),len(mat_data),'errors',errors,flush=True)
return library
if __name__=='__main__':prepare(json.loads(Path(sys.argv[1]).read_text()))
@@ -0,0 +1,118 @@
"""Read native CAC settings without changing the gameplay collections schema.
The XML catalog contains models/materials; BIN/VLT contains preset arrays,
colour parameters, morph order/ranges, cameras and menu filters. Preserve both.
Raw reflected fields remain authoritative; decoded helpers only cover layouts
corroborated with the native code. See docs/character-customisation.md.
"""
from __future__ import annotations
import hashlib
import math
from pathlib import Path
import struct
from tools.asset_pipeline.vlt import convert, hash64
from tools.owned_game.big import BigArchive
DATABASE_FILES = {
"skaterschema.bin", "skaterschema.vlt", "skatercollections.bin",
"skatercollections.vlt", "skaterschema_summaryreport.txt",
}
# Reflected layout offsets are independently corroborated by 8253CA60..CD18.
MORPH_CLASS = 0x7F47CA62F4100F41
MORPH_FIELDS = {
"target": 0xAAC0F30B119BD14F, # layout +0
"target_order": 0x0D381DFA82C751E1, # layout +4
"ui_zone": 0x3D15CA957C745188, # layout +8; not always target_order!
"min_a": 0x0279ED3135F03661, # layout +12
"max_a": 0xD4340AC527D228E1, # layout +16
"default_a": 0x49E3727FEB2F6475, # layout +20
"min_b": 0x11691A8EEF816175, # layout +24
"max_b": 0x7680C10C0F43EB3E, # layout +28
"default_b": 0xBE3909745E8A3935, # layout +32
}
def key_hash(key: str) -> int:
return int(key[5:], 16) if key.startswith("Hash_") else hash64(key)
def fields_by_hash(row: dict) -> dict[int, dict]:
return {key_hash(key): value for key, value in row["fields"].items()}
def float32(field: dict) -> float:
data = bytes.fromhex(field["data"])
if field["type"] != "EA::Reflection::Float" or len(data) != 4:
raise ValueError("Expected one native Float field")
value, = struct.unpack(">f", data)
if not math.isfinite(value):
raise ValueError("Non-finite native Float field")
return value
def decode_morphs(rows: list[dict]) -> list[dict]:
result = []
for row in rows:
if key_hash(row["class"]) != MORPH_CLASS:
continue
fields = fields_by_hash(row)
values = {name: fields[key] for name, key in MORPH_FIELDS.items()}
order, = struct.unpack(">i", bytes.fromhex(values["target_order"]["data"]))
zone, = struct.unpack(">I", bytes.fromhex(values["ui_zone"]["data"]))
if order == -1:
continue # schema default, not an additional user morph
if not 0 <= order < 19 or not 0 <= zone < 19:
raise ValueError("Native morph index outside the 19-field container")
if values["target"]["type"] != "EA::Reflection::Text":
raise ValueError("Undecoded native morph target name")
entry = {"key": row["key"], "target": values["target"]["data"],
"target_order": order, "ui_zone": zone}
# Retain native branch labels until the gender boolean's meaning is
# verified. The inspected retail bank gives identical A/B ranges.
for branch in ("a", "b"):
bounds = {name: float32(values[f"{name}_{branch}"])
for name in ("min", "max", "default")}
if not bounds["min"] <= bounds["default"] <= bounds["max"] or bounds["min"] == bounds["max"]:
raise ValueError(f"Invalid native morph range: {row['key']}")
entry[f"branch_{branch}"] = bounds
result.append(entry)
if len(result) != 19 or {m["target_order"] for m in result} != set(range(19)):
raise ValueError("Native morph target order is incomplete or duplicated")
if len({m["target"] for m in result}) != 19:
raise ValueError("Duplicate native morph target name")
return sorted(result, key=lambda m: m["target_order"])
def read_database(game_root: Path, staging: Path) -> dict:
# Import locally to avoid a module cycle with the catalog's optional stage.
from tools.asset_pipeline.customisation_catalog import write_private
game_root = game_root.resolve(strict=True)
staging = staging.resolve()
if staging == game_root or staging.is_relative_to(game_root) or game_root.is_relative_to(staging):
raise ValueError("Database staging must be separate from owned source")
archive = BigArchive(game_root / "data/big/db.big")
entries = [e for e in archive.entries if e.path.lower().startswith("data/db/")
and Path(e.path).name.lower() in DATABASE_FILES]
if len(entries) != len(DATABASE_FILES):
raise ValueError("Missing or ambiguous owned CAC BIN/VLT inputs")
sources = []
for entry in entries:
data = archive.read(entry)
name = Path(entry.path).name.lower()
write_private(staging / name, data)
sources.append({"entry": entry.path, "size": len(data),
"sha256": hashlib.sha256(data).hexdigest()})
# The game ships its class/collection names in this report. No local dump
# or machine-specific reverse-engineering name cache is needed.
names = Path(__file__).with_name("names.txt").read_text(encoding="utf-8").splitlines()
for line in (staging / "skaterschema_summaryreport.txt").read_text(encoding="utf-8").splitlines():
names.extend(line.replace(".class", "").replace(".xml", "").split("/"))
names.extend(["Sk8::CAC::MorphPreset", "Sk8::CAC::ColourPreset", "Sk8::CAC::MORPH_ZONES"])
data = convert(staging / "skaterschema", staging / "skatercollections", names)
rows = [r for r in data["collections"] if r["class"].startswith(("cac_", "cas_"))]
return {"version": 1, "sources": sources, "collections": rows, "morphs": decode_morphs(rows)}
@@ -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,260 @@
"""Private, on-demand retail CAC assembly. Never starts a game or an editor.
The protocol is JSON on stdin/stdout. Paths belong to a local worker.json,
not the saved outfit. Failed assemblies never replace an existing GLB.
"""
from __future__ import annotations
import argparse, copy, hashlib, json, math, os, shutil, sys
from pathlib import Path
from tools.asset_pipeline.customisation_catalog import write_private, selected_paths
ROOT = Path(__file__).resolve().parents[2]
def flag(item, key):
return item['flags'].get('cas.' + key, '')
def label(name):
return name.replace('_', ' ').strip()
def default_profile():
recipe = json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())
return {'selections': {c['slot']: {'asset_id': c['asset_id'], 'material_id': c['material_id']}
for c in recipe['components']}, 'morphs': {},
'truck': 0.7, 'wheel': 0.7, 'posture': 0}
def menu_index(catalog, native):
"""Use authored names and IDs; no generated merchandise or synthetic presets."""
mats = catalog['materials']
slots = {c['slot']: c['models'] for c in catalog['components']}
def page(name, children=None, **extra):
return dict(label=name, children=children or [], **extra)
def unavailable(name, reason):
return page(name, note=reason)
def models(name, slot, predicate=lambda m: True):
children = []
if slot in {'Hat', 'Hair', 'Glasses', 'Jewellery', 'WristItem', 'Sock', 'Accessory'}:
children.append(page('None', patch={'selections': {slot: None}}))
for m in slots[slot]:
if flag(m, 'Gender') not in {'', 'male', 'unisex'} or not predicate(m):
continue
lod = next(l for l in m['lods'] if l['index'] == 0)
if len(lod['material_instances']) != 1:
continue
variants = []
for v in lod['material_instances'][0]:
if not any(t['channel'] == 'diffuse' for t in mats[v['id']]['textures']):
continue
variants.append(page(label(v['name']), patch={'selections': {slot: {
'asset_id': m['id'], 'material_id': v['id']}}}))
if variants:
children.append(page(label(m['name']), variants))
return page(name, children)
morphs = []
for m in native['morphs']:
r = m['branch_a']
morphs.append(page(label(m['key']), scalar='morphs.'+m['target'],
minimum=r['min'], maximum=r['max'], initial=r['default'], step=0.025))
skin = sorted({flag(m, 'SkinTone') for m in mats.values()} - {''})
beard = sorted({flag(m, 'FacialHairStyle') for m in mats.values()} - {''})
body = page('Body mods', [
unavailable('Gender', 'Male is supported in this test. Female material groups are still being decoded.'),
page('Skin', [page(x, patch={'skin': x}) for x in skin]), models('Hair', 'Hair'),
page('Body shape', morphs[:2]),
unavailable('Facial presets', 'Native preset arrays are indexed; preset application is not yet connected.'),
page('Face modification', morphs[2:]),
page('Facial hair', [page(x, patch={'beard': x}) for x in beard]),
unavailable('Upper body tattoo', 'Tattoo stamp compositing is not yet implemented.'),
unavailable('Lower body tattoo', 'Tattoo stamp compositing is not yet implemented.')])
merch = [models('Hats', 'Hat')]
for title, kinds in [('Tshirts', {'tshirt','ls_tshirt','tanktop'}), ('Button shirts', {'buttonshirt'}),
('Hoodies', {'hoody'}), ('Jackets', {'jacket'}), ('Sweaters', {'sweater'})]:
merch.append(models(title, 'OuterTorso', lambda m, k=kinds: flag(m,'TopType') in k))
merch += [models('Pants','Pants'), models('Shoes','Feet'), models('Socks','Sock'),
page('Accessories', [models(s, s) for s in ['Glasses','Jewellery','WristItem','Accessory']]),
page('Skateboards', [models('Deck','SkateBoard'),models('Trucks','SkateTruck'),models('Wheels','SkateWheel')])]
style = page('Edit style', [unavailable('Gestures', 'Gesture selection is not yet connected.'),
unavailable('Stance', 'Changing the native stance publication is not yet connected.'),
unavailable('Style', 'Native style-to-animation selection is still being decoded.'),
page('Posture', [page(x, patch={'posture':i}) for i,x in enumerate(['Default','Stiff','Slouch','Buff'])])])
return page('Character customiser', [body, page('Merchandise', merch), style,
page('Trucks adjustment',scalar='truck',minimum=0.,maximum=1.,initial=.7,step=.1),
page('Wheels adjustment',scalar='wheel',minimum=0.,maximum=1.,initial=.7,step=.1)])
def resolve(catalog, profile):
profile = copy.deepcopy(profile)
models = {m['id']: (c['slot'],m) for c in catalog['components'] for m in c['models']}
materials = catalog['materials']
selections = profile['selections']
# Validate even entries which would subsequently be removed by a dependency.
for slot, selection in list(selections.items()):
if selection is None:
del selections[slot]
continue
if selection['asset_id'] not in models or models[selection['asset_id']][0] != slot:
raise ValueError('Unknown model in '+slot)
m = models[selection['asset_id']][1]
if flag(m,'Gender') not in {'','male','unisex'}:
raise ValueError('Female assembly is not supported by this test build')
groups = next(l for l in m['lods'] if l['index']==0)['material_instances']
if len(groups)!=1 or selection['material_id'] not in {v['id'] for v in groups[0]}:
raise ValueError('Invalid material binding for '+slot)
def model(slot):
return models[selections[slot]['asset_id']][1] if slot in selections else None
def choose(slot, key, value):
if not value or value == 'none':
selections.pop(slot, None)
return
current=model(slot)
if current and flag(current,key)==value:
return
candidates=[m for s,m in models.values() if s==slot and flag(m,key)==value
and flag(m,'Gender') in {'','male','unisex'}]
if not candidates:
raise ValueError(f'No compatible {slot}: {key}={value}')
m=candidates[0]
variants=next(l for l in m['lods'] if l['index']==0)['material_instances'][0]
v=next((v for v in variants if flag(materials[v['id']],'IsDefault').lower()=='yes'), variants[0])
selections[slot]={'asset_id':m['id'],'material_id':v['id']}
outer=model('OuterTorso')
if outer:
choose('Arm','ArmModelType',flag(outer,'ArmModelRequired'))
inner=flag(outer,'InnerCutTypeRequired')
choose('InnerTorso','InnerCutType',inner)
if flag(outer,'SupportsWristItem')=='no':selections.pop('WristItem',None)
if flag(outer,'IsNecklaceRemoved').lower()=='yes':selections.pop('Jewellery',None)
pants=model('Pants')
if pants:choose('Leg','LegModelType',flag(pants,'LegModelRequired'))
leg=model('Leg')
if leg:
sock=selections.get('Sock')
sock_style=flag(materials[sock['material_id']],'SockStyle') if sock else 'none'
candidates=[m for s,m in models.values() if s=='Leg' and flag(m,'Gender') in {'male','unisex',''}
and flag(m,'LegModelType')==flag(leg,'LegModelType')
and (flag(m,'RequiresSockStyle') or 'none')==sock_style]
if not candidates:raise ValueError('These socks do not fit the selected pants; choose another sock length')
if leg not in candidates:
m=candidates[0];variants=m['lods'][0]['material_instances'][0]
previous=selections['Leg']['material_id']
v=next((v for v in variants if v['id']==previous),variants[0])
selections['Leg']={'asset_id':m['id'],'material_id':v['id']}
hat=model('Hat')
if hat:
required=flag(hat,'RequiresHairModelType')
hair=model('Hair')
if required=='none':selections.pop('Hair',None)
elif required and hair and flag(hair,'HairModelType')!=required:
matches=[m for s,m in models.values() if s=='Hair' and flag(m,'Gender') in {'male','unisex',''}
and flag(m,'HairStyle')==flag(hair,'HairStyle') and flag(m,'HairModelType')==required]
if not matches:raise ValueError('This hat needs another hair style; choose None under Hair first')
m=matches[0];v=m['lods'][0]['material_instances'][0][0]
selections['Hair']={'asset_id':m['id'],'material_id':v['id']}
for slot in list(selections):
m=model(slot)
if m:
for removed in flag(m,'RequiresRemovalOfComponents').split(','):
selections.pop(removed.strip(),None)
# Keep exposed body material variants on the selected head's authored tone.
head=selections['Rostral']
tone=profile.get('skin',flag(materials[head['material_id']],'SkinTone'))
beard=profile.get('beard',flag(materials[head['material_id']],'FacialHairStyle'))
for slot,sel in selections.items():
old=materials[sel['material_id']]
if not flag(old,'SkinTone'):continue
variants=model(slot)['lods'][0]['material_instances'][0]
candidates=[v for v in variants if flag(materials[v['id']],'SkinTone')==tone
and (slot!='Rostral' or flag(materials[v['id']],'FacialHairStyle')==beard)]
if not candidates:raise ValueError(f'No authored {slot} material for skin {tone} / facial hair {beard}')
# Preserve unrelated eyebrow, hairline and skin-normal flags where possible.
def distance(v):
f=materials[v['id']]['flags']
return sum(f.get(k)!=val for k,val in old['flags'].items() if k not in {'cas.SkinTone','cas.FacialHairStyle'})
if not any(v['id']==sel['material_id'] for v in candidates):
sel['material_id']=min(candidates,key=distance)['id']
allowed={'fat','thin'}|set(json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())['morph_assembly']['face_targets'])
for key,value in profile.get('morphs',{}).items():
if key not in allowed:raise ValueError('Unknown morph target: '+key)
if not isinstance(value,(int,float)) or not math.isfinite(value) or not 0<=value<=.5:
raise ValueError('Morph value outside the native range: '+key)
return profile
def build(config, requested):
from tools.owned_game.big import BigArchive
from tools.extract_default_skater import import_rx2_parser, decode_texture, build_composite_images
from tools.asset_pipeline.retail_character import RX2, decode_dense_morphs
from tools.asset_pipeline.character_glb import convert
assets=Path(config['assets']); cache=assets/'private/customisation'
catalog=json.loads((cache/'catalog.json').read_text())
profile=resolve(catalog,requested)
key=hashlib.sha256(json.dumps(profile,sort_keys=True).encode()).hexdigest()[:24]
work=cache/'builds'/key; output=work/'character.glb'
if output.is_file():return {'profile':profile,'scene':output.relative_to(assets).as_posix()}
work.mkdir(parents=True,exist_ok=True)
indexed={m['id']:m for c in catalog['components'] for m in c['models']}
selections=[dict(slot=s,asset_id=v['asset_id'],lod=0,material_ids=[v['material_id']])
for s,v in profile['selections'].items()]
paths=selected_paths(catalog,selections)
archive=BigArchive(Path(config['game_root'])/'data/content/createacharacter.big')
entries={e.path.lower():e for e in archive.entries}
for path in paths:
dest=cache/'source'/path
if not dest.is_file():write_private(dest,archive.read(entries[path.lower()]))
parser=import_rx2_parser(ROOT/'tools/vendor/utt')
decoded=cache/'decoded'; decoded.mkdir(exist_ok=True)
recipe=json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())
original={c['slot']:c for c in recipe['components']}
recipe['components']=[];recipe['morph_assembly']['expected_targets']={}
for slot,sel in profile['selections'].items():
m=indexed[sel['asset_id']];lod=next(l for l in m['lods'] if l['index']==0)
mat=catalog['materials'][sel['material_id']]
textures={t['channel']:t['id'] for t in mat['textures']}
if 'diffuse' not in textures:raise ValueError('No diffuse map for '+slot)
for t in mat['textures']:
dest=decoded/(t['id']+'.png')
if not dest.is_file():decode_texture(parser,cache/'source'/t['path'],dest)
target=work/'models'/slot/Path(lod['path']).name
target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists():shutil.copyfile(cache/'source'/lod['path'],target)
parsed=RX2.parse_rx2(str(target));meshes=[m for m in parsed['meshes'] if m.get('positions') and m.get('indices')]
if len(meshes)!=1:raise ValueError('Unsupported mesh groups in '+slot)
morphs=decode_dense_morphs(target,parsed,len(meshes[0]['positions']),RX2)
recipe['morph_assembly']['expected_targets'][slot]=[m['name'] for m in morphs]
base=original.get(slot,{})
# Preserve the existing renderer and its baseline material constants.
tint=base.get('tint',[1.,1.,1.]) if base.get('material_id')==sel['material_id'] else [1.,1.,1.]
recipe['components'].append(dict(slot=slot,textures=textures,tint=tint,
alpha_mode='MASK' if 'alpha' in textures else 'OPAQUE'))
body=recipe['preset']['body_mods'];values=profile.get('morphs',{})
body.update(fatness=values.get('fat',0.),skinniness=values.get('thin',0.),targets=values)
material_root=work/'materials'
build_composite_images(recipe,decoded,material_root)
temporary=work/'character.pending.glb'
convert(work/'models',assets/'private',recipe,output=temporary,materials=material_root)
os.replace(temporary,output)
(work/'recipe.json').write_text(json.dumps(recipe,indent=2))
return {'profile':profile,'scene':output.relative_to(assets).as_posix()}
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--config',type=Path,required=True)
args=p.parse_args();config=json.loads(args.config.read_text())
request=json.load(sys.stdin)
try:
import contextlib
with contextlib.redirect_stdout(sys.stderr):
result=build(config,request)
except Exception as e:
import traceback
traceback.print_exc(file=sys.stderr)
result={'error':str(e)}
print(json.dumps(result))
return 1 if 'error' in result else 0
if __name__=='__main__':sys.exit(main())
+6 -2
View File
@@ -333,7 +333,7 @@ def morph_weight(name: str, manifest: dict) -> float:
if name == "thin" or name.startswith("thin_"):
return float(body["skinniness"])
if name in manifest["morph_assembly"]["face_targets"]:
return float(body["face_fields"])
return float(body.get("targets", {}).get(name, body["face_fields"]))
raise RuntimeError(f"Retail morph target has no recipe mapping: {name}")
def decode_dense_morphs(
@@ -424,6 +424,7 @@ def decode_dense_morphs(
)
deltas = []
normal_deltas = []
nonzero_vertices = 0
nonzero_normal_deltas = 0
max_delta = 0.0
@@ -434,7 +435,9 @@ def decode_dense_morphs(
x, y, z, _remap = struct.unpack_from(
">4h", data, vertex_offset
)
if be_u32(data, vertex_offset + 8) != 0:
packed_normal = be_u32(data, vertex_offset + 8)
normal_deltas.append(rx2_skeleton._dec_11_11_10(packed_normal))
if packed_normal != 0:
nonzero_normal_deltas += 1
if be_u32(data, vertex_offset + 12) != 0x3F800000:
raise RuntimeError(
@@ -456,6 +459,7 @@ def decode_dense_morphs(
"hash": f"{target_hash:016x}",
"descriptor_index": descriptor_index,
"deltas": deltas,
"normal_deltas": normal_deltas,
"nonzero_vertices": nonzero_vertices,
"nonzero_normal_deltas": nonzero_normal_deltas,
"max_source_delta": max_delta,
@@ -0,0 +1,130 @@
"""Synthetic CAC records only; no game content is needed or stored here."""
from pathlib import Path
import struct
import tempfile
import unittest
from tools.asset_pipeline.customisation_catalog import (
MODEL_PREFIX, TEXTURE_PREFIX, parse_catalog, prepare,
selected_paths, write_private,
)
from tools.asset_pipeline.customisation_native import MORPH_FIELDS, decode_morphs
M1 = "0000000000000001"
M2 = "0000000000000002"
MODEL = "0000000000000020"
ASSET = "0000000000000030"
TEX1 = "0000000000000040"
TEX2 = "0000000000000050"
def fixture():
xml = f'''<compositeasset n="cas_db" type="CreateACharacter" schemaversion="6.0">
<mat id="0x{M1}" type="test_shader">
<ud n="cas"><cas.Gender value="female"/><cas.RequiresRemovalOfComponents value="Hat,Hair"/></ud>
<sp chn="diffuse" id="0x{TEX1}" uv="1" sampler="authored"/>
<scalar value="0.375"/>
</mat>
<mat id="0x{M2}" type="test_shader_second"><sp chn="alpha" id="0x{TEX2}"/></mat>
<comp n="Head"><mod id="0x{ASSET}" n="synthetic_head">
<lod idx="0" id="0x0000000000000060" arenaid="0x{MODEL}">
<matinst><matvar id="0x{M1}" n="first"/></matinst>
<matinst><matvar id="0x{M2}" n="second"/></matinst>
</lod>
</mod></comp></compositeasset>'''.encode()
paths = {f"{MODEL_PREFIX}/Head/0x{MODEL}.rx2",
f"{TEXTURE_PREFIX}/0x{TEX1}.rx2", f"{TEXTURE_PREFIX}/0x{TEX2}.rx2"}
selection = [{"slot": "Head", "asset_id": ASSET, "lod": 0, "material_ids": [M1, M2]}]
return xml, paths, selection
class CatalogTests(unittest.TestCase):
def test_preserves_material_instance_order_and_authored_shader_inputs(self):
xml, paths, selection = fixture()
catalog = parse_catalog(xml, paths)
lod = catalog["components"][0]["models"][0]["lods"][0]
self.assertEqual(lod["model_id"], MODEL) # arenaid names the RX2, not id
self.assertEqual([group[0]["id"] for group in lod["material_instances"]], [M1, M2])
definition = catalog["materials"][M1]["authored"]["children"]
self.assertEqual(definition[1]["attributes"]["sampler"], "authored")
self.assertEqual(definition[1]["attributes"]["uv"], "1")
self.assertEqual(definition[2]["attributes"]["value"], "0.375")
self.assertEqual(catalog["materials"][M1]["flags"]["cas.RequiresRemovalOfComponents"], "Hat,Hair")
self.assertEqual(set(selected_paths(catalog, selection)), paths)
def test_rejects_incomplete_or_reordered_material_selection(self):
xml, paths, selection = fixture()
catalog = parse_catalog(xml, paths)
for choices in ([M1], [M2, M1], [M1, "000000000000ffff"]):
selection[0]["material_ids"] = choices
with self.assertRaises(ValueError):
selected_paths(catalog, selection)
def test_missing_archived_texture_is_reported_and_cannot_be_extracted(self):
xml, paths, selection = fixture()
paths.remove(f"{TEXTURE_PREFIX}/0x{TEX2}.rx2")
catalog = parse_catalog(xml, paths)
self.assertFalse(catalog["materials"][M2]["textures"][0]["in_archive"])
with self.assertRaisesRegex(ValueError, "Selected texture is absent"):
selected_paths(catalog, selection)
def test_rejects_broken_material_references_and_path_slots(self):
xml, paths, _ = fixture()
broken = xml.replace(f'<matvar id="0x{M1}"'.encode(), b'<matvar id="0x000000000000ffff"')
with self.assertRaisesRegex(ValueError, "Missing material definition"):
parse_catalog(broken, paths)
with self.assertRaisesRegex(ValueError, "Invalid or duplicate component"):
parse_catalog(xml.replace(b'comp n="Head"', b'comp n="../Head"'), paths)
def test_never_overwrites_staged_content_and_reuses_identical_files(self):
with tempfile.TemporaryDirectory() as root:
target = Path(root) / "private" / "resource"
self.assertEqual(write_private(target, b"first"), "written")
self.assertEqual(write_private(target, b"first"), "reused")
with self.assertRaisesRegex(ValueError, "Refusing to replace"):
write_private(target, b"second")
self.assertEqual(target.read_bytes(), b"first")
self.assertEqual([p.name for p in target.parent.iterdir()], ["resource"])
def test_rejects_staging_inside_or_above_owned_source_before_opening_archive(self):
with tempfile.TemporaryDirectory() as root:
source = Path(root) / "owned"
source.mkdir()
for destination in (source, source / "overlay", Path(root)):
with self.assertRaisesRegex(ValueError, "separate from the owned source"):
prepare(source, destination)
def morph_rows():
rows = []
for index in range(19):
values = {"target": {"type": "EA::Reflection::Text", "data": f"target_{index}"},
"target_order": {"type": "EA::Reflection::Int32", "data": struct.pack(">i", index).hex()},
"ui_zone": {"type": "zone", "data": struct.pack(">I", 18 - index).hex()}}
for branch in ("a", "b"):
for name, number in (("min", 0.0), ("max", 0.5), ("default", 0.25)):
values[f"{name}_{branch}"] = {"type": "EA::Reflection::Float", "data": struct.pack(">f", number).hex()}
rows.append({"class": "cac_morph_params", "key": f"field_{index}", "fields": {
f"Hash_{MORPH_FIELDS[name]:016X}": value for name, value in values.items()}})
return rows
class NativeMetadataTests(unittest.TestCase):
def test_target_order_and_ui_zone_remain_independent(self):
decoded = decode_morphs(list(reversed(morph_rows())))
self.assertEqual(decoded[3]["target_order"], 3)
self.assertEqual(decoded[3]["ui_zone"], 15)
self.assertEqual(decoded[3]["branch_a"], {"min": 0.0, "max": 0.5, "default": 0.25})
def test_rejects_missing_targets_and_nonfinite_ranges(self):
with self.assertRaisesRegex(ValueError, "incomplete or duplicated"):
decode_morphs(morph_rows()[:-1])
rows = morph_rows()
rows[3]["fields"][f"Hash_{MORPH_FIELDS['max_a']:016X}"]["data"] = "7fc00000"
with self.assertRaisesRegex(ValueError, "Non-finite"):
decode_morphs(rows)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,95 @@
"""Offline CAC preparation checks; no window, game process, or renderer."""
import json
import os
import struct
import unittest
from pathlib import Path
import numpy as np
from tools.asset_pipeline.customisation_library import friendly, variant_label
from tools.asset_pipeline.retail_character import RX2
class LibraryTests(unittest.TestCase):
def test_secondary_uv_is_float2_not_four_normalized_shorts(self):
data = struct.pack('>4f', .125, .75, .875, .625)
description = {'stride': 8, 'elements': [{'usage_name': 'TEXCOORD',
'usage_index': 1, 'format_name': RX2.VTX_FORMATS[0x002C23A5][0], 'offset': 0}]}
self.assertEqual(RX2._decode_vertices(data, 0, 2, description)['uvs2'],
[(.125, .75), (.875, .625)])
def test_menu_names_remove_exporter_words_and_explain_wear(self):
self.assertEqual(friendly('male_longsleeve_tshirt_stampable_sleeves_up'), 'Rolled-Sleeve Tee')
self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material_d'), 'Worn')
self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material'), 'New')
self.assertEqual(friendly('tattoo_biglizard_mat'), 'Big Lizard')
@unittest.skipUnless(os.environ.get('SKATE_CAC_TEST_LIBRARY'), 'owned library is opt-in')
def test_owned_geometry_and_dependencies(self):
path = Path(os.environ['SKATE_CAC_TEST_LIBRARY'])
library = json.loads(path.read_text(encoding='utf-8'))
assets = path.parents[2]
self.assertEqual(library['errors'], [])
self.assertEqual(len(library['models']), 480)
self.assertEqual(len(library['tattoos']), 170)
for part in library['models'].values():
data = (assets / part['scene']).read_bytes()
magic, version, size, text_size = struct.unpack_from('<4I', data)
self.assertEqual((magic, version, size), (0x46546C67, 2, len(data)))
doc = json.loads(data[20:20+text_size])
blob = data[28+text_size:]
def array(index):
accessor = doc['accessors'][index]
view = doc['bufferViews'][accessor['bufferView']]
width = {'SCALAR':1, 'VEC2':2, 'VEC3':3, 'VEC4':4, 'MAT4':16}[accessor['type']]
result = np.frombuffer(blob, dtype={5126:'<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'] == 'Hair':
self.assertIn('TEXCOORD_1', a)
self.assertEqual(len(array(a['TEXCOORD_1'])), 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','opacity']:
if material.get(channel): self.assertTrue((assets/material[channel]).is_file())
# V3 preserves the native hair opacity texture independently of diffuse.
hair = [m for m in library['materials'].values() if m.get('opacity')]
self.assertEqual(len(hair), 31)
for material in hair:
self.assertNotEqual(material['diffuse'], material['opacity'])
self.assertIn('/decoded/', material['diffuse'])
self.assertTrue(material['alpha'])
# Native moussed-up hair demonstrates why diffuse A cannot be coverage.
from PIL import Image
sample = library['materials']['00000c5603e38817']
diffuse = np.asarray(Image.open(assets/sample['diffuse']).convert('RGBA'))
opacity = np.asarray(Image.open(assets/sample['opacity']).convert('RGBA'))
self.assertLess(np.mean(diffuse[:,:,3] >= 128), .01)
self.assertGreater(np.mean(opacity[:,:,0] >= 128), .4)
for tattoo in library['tattoos'].values():
self.assertTrue((assets/tattoo['texture']).is_file())
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,55 @@
"""Non-game protocol and dependency tests, with synthetic catalog data."""
import copy
import unittest
from tools.asset_pipeline.customisation_worker import resolve
from tools.asset_pipeline.retail_character import morph_weight
def fixture():
def mat(tone):return {'flags':{'cas.SkinTone':tone,'cas.FacialHairStyle':'none'}}
def model(id, gender='male', **flags):
return {'id':id,'flags':{'cas.Gender':gender,**{'cas.'+k:v for k,v in flags.items()}},
'lods':[{'index':0,'material_instances':[[{'id':'light'},{'id':'dark'}]]}]}
catalog={'materials':{'light':mat('light'),'dark':mat('dark')},'components':[
{'slot':'Rostral','models':[model('head'),model('female','female')]},
{'slot':'OuterTorso','models':[model('top',ArmModelRequired='wrist')]},
{'slot':'Arm','models':[model('long_arm',ArmModelType='shoulder'),model('wrist',ArmModelType='wrist')]}]}
profile={'selections':{'Rostral':{'asset_id':'head','material_id':'light'},
'OuterTorso':{'asset_id':'top','material_id':'light'},
'Arm':{'asset_id':'long_arm','material_id':'light'}},'morphs':{}}
return catalog,profile
class WorkerTests(unittest.TestCase):
def test_dependency_replacement_does_not_mutate_request(self):
c,p=fixture();before=copy.deepcopy(p);out=resolve(c,p)
self.assertEqual(p,before)
self.assertEqual(out['selections']['Arm']['asset_id'],'wrist')
def test_tone_propagates_to_exposed_skin(self):
c,p=fixture();p['skin']='dark';out=resolve(c,p)
self.assertTrue(all(v['material_id']=='dark' for v in out['selections'].values()))
def test_rejects_cross_model_material_and_unsupported_gender(self):
c,p=fixture();p['selections']['Rostral']['material_id']='missing'
with self.assertRaisesRegex(ValueError,'Invalid material'):resolve(c,p)
p['selections']['Rostral']={'asset_id':'female','material_id':'light'}
with self.assertRaisesRegex(ValueError,'Female'):resolve(c,p)
def test_rejects_out_of_range_and_unmapped_morphs(self):
c,p=fixture()
for values in [{'fat':.6},{'thin':float('nan')},{'invented':.25}]:
p['morphs']=values
with self.assertRaises(ValueError):resolve(c,p)
def test_per_target_face_values_preserve_other_defaults(self):
recipe={'preset':{'body_mods':{'fatness':.4,'skinniness':.1,'face_fields':.25,
'targets':{'local_nose_length':.5}}},
'morph_assembly':{'face_targets':['local_nose_height','local_nose_length']}}
self.assertEqual(morph_weight('local_nose_length',recipe),.5)
self.assertEqual(morph_weight('local_nose_height',recipe),.25)
self.assertEqual(morph_weight('fat_arms',recipe),.4)
self.assertEqual(morph_weight('thin_arms',recipe),.1)
if __name__=='__main__':unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""Synthetic fixtures for the binary array layout; contains no game data."""
import struct
import unittest
from .vlt import array_items, array_text_items
class ArrayLayoutTests(unittest.TestCase):
def test_vector4_header_alignment(self):
first = struct.pack('>4f', .75, .35, 0, 0)
second = struct.pack('>4f', 1, 1, 1, 1)
raw = struct.pack('>4H', 2, 2, 16, 0x8000) + bytes(8) + first + second
result = array_items(raw, 0, 16, 4)
self.assertEqual(result['items'], [first.hex().upper(), second.hex().upper()])
self.assertEqual(result['alignment'], 16)
def test_each_element_is_aligned_at_absolute_offset(self):
a, b = struct.pack('>3f', 2, 3, 4), struct.pack('>3f', 5, 6, 7)
raw = bytes(4) + struct.pack('>4H', 3, 2, 12, 0) + bytes(4)
raw += a + bytes(4) + b + bytes(4) + bytes(12)
result = array_items(raw, 4, 12, 4)
self.assertEqual(result['capacity'], 3)
self.assertEqual(result['items'], [a.hex().upper(), b.hex().upper()])
def test_empty_and_pointer_arrays(self):
self.assertEqual(array_items(struct.pack('>4H', 0, 0, 4, 0), 0, 4, 2)['items'], [])
raw = struct.pack('>4H2I', 2, 2, 4, 0, 0x1234, 0)
self.assertEqual(array_items(raw, 0, 4, 2)['items'], ['00001234', '00000000'])
def test_invalid_header_or_truncated_capacity_fails(self):
for raw in [struct.pack('>4H', 1, 2, 4, 0), struct.pack('>4H', 1, 1, 8, 0),
struct.pack('>4HI', 2, 1, 4, 0, 123)]:
with self.assertRaises(ValueError):
array_items(raw, 0, 4, 2)
def test_text_array_resolves_relocated_pointers(self):
binary = bytes(8) + 'test\0caf\u00e9\0'.encode()
self.assertEqual(array_text_items(binary, ['00000008', '0000000D', '00000000']),
['test', 'caf\u00e9', ''])
with self.assertRaises(ValueError):
array_text_items(binary, ['0000FFFF'])
if __name__ == '__main__':
unittest.main()
+43
View File
@@ -43,6 +43,42 @@ def take(data, at, size):
def unpack(fmt,data,at):
return struct.unpack('>'+fmt,take(data,at,struct.calcsize('>'+fmt)))
def array_items(data, pos, size, alignment):
"""ModernBase array: four u16 header lanes, then aligned elements.
Alignment in the schema is log2(bytes), not the array header's mask.
Retain each element separately: padding is not part of its value.
"""
capacity, count, stride, _ = unpack('4H', data, pos)
if count > capacity or stride != size or not stride or alignment > 16:
raise ValueError(f'Invalid VLT array at {pos:#x}')
boundary = 1 << alignment
cursor = pos + 8
items = []
for index in range(capacity):
cursor = (cursor + boundary - 1) & -boundary
raw = take(data, cursor, stride)
if index < count:
items.append(raw.hex().upper())
cursor += stride
return {'capacity': capacity, 'element_size': stride,
'alignment': boundary, 'items': items}
def array_text_items(binary, items):
values = []
for raw in items:
ptr, = unpack('I', bytes.fromhex(raw), 0)
if not ptr:
values.append('')
continue
end = binary.find(b'\0', ptr)
if end < 0:
raise ValueError(f'Unterminated VLT array text at {ptr:#x}')
values.append(take(binary, ptr, end-ptr).decode('utf-8'))
return values
def chunks(data):
at=0
while at<len(data):
@@ -114,6 +150,13 @@ def convert(schema_stem, collections_stem, names):
else:
raw=take(data,pos,length)
out[name(fkey)]={'type':t,'data':raw.hex().upper()}
if flags&1:
# Keep the legacy data field unchanged for existing
# gameplay consumers. New readers use the full array.
out[name(fkey)]['array']=array_items(data,pos,n,alignment)
if t=='EA::Reflection::Text':
array=out[name(fkey)]['array']
array['text_items']=array_text_items(cb,array['items'])
for fk,f in fields.items():
if f[4]&2 and layout: value(fk,cb,layout+f[1])
entries=at+48+typeslen*8
+27
View File
@@ -0,0 +1,27 @@
@echo off
setlocal
cd /d "%~dp0"
if not exist "%~dp0skate3rust-customiser-v4.exe" (
echo The V4 customiser executable is missing.
pause
exit /b 1
)
if not exist "%~dp0assets\private\customisation\library-v3.json" (
echo The prepared character library is missing.
pause
exit /b 1
)
echo Skate 3 Character Customiser V4
echo Open Escape / Start, then Character customiser.
echo Browse with arrows / D-pad. Change with left-right or the minus-plus buttons.
echo Right stick rotates your character.
echo Type to search an item list. Escape / B goes back. Done saves and resumes.
echo Diagnostic output: customiser-v4-session.log
"%~dp0skate3rust-customiser-v4.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-v4-session.log" 2>&1
set "skate_customiser_exit=%errorlevel%"
if not "%skate_customiser_exit%"=="0" (
echo The test exited with code %skate_customiser_exit%.
echo See %~dp0customiser-v4-session.log
pause
)
exit /b %skate_customiser_exit%
+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)