mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Merge character customisation with map and teleport lifecycle
This commit is contained in:
commit
26f056cf34
32 files changed
+4406
-38
No files matched your search
@@ -29,6 +29,7 @@ fn bind(
|
||||
skins: Query<(Entity, &SkinnedMesh)>,
|
||||
nodes: Query<(&Name, &Transform)>,
|
||||
parents: Query<&ChildOf>,
|
||||
visibility: Query<&Visibility>,
|
||||
roots: Query<Entity, With<PlayerRoot>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
@@ -37,12 +38,16 @@ fn bind(
|
||||
}
|
||||
let names = &skater.animation.evaluator.frames.bone_names;
|
||||
let result = (|| -> Result<Option<Vec<BoneBinding>>, String> {
|
||||
let mut bindings = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (entity, skin) in &skins {
|
||||
if !parents.iter_ancestors(entity).any(|e| roots.contains(e)) {
|
||||
continue;
|
||||
}
|
||||
if parents.iter_ancestors(entity).any(|e| visibility.get(e).is_ok_and(|v| *v == Visibility::Hidden)) { continue; }
|
||||
let mut binding = Vec::with_capacity(skin.joints.len());
|
||||
for &joint in &skin.joints {
|
||||
if !seen.insert(joint) { continue; }
|
||||
let (name, _) = nodes
|
||||
.get(joint)
|
||||
.map_err(|_| "Skater skin joint is missing its name or transform")?;
|
||||
@@ -80,13 +85,10 @@ fn bind(
|
||||
parent_bone,
|
||||
});
|
||||
}
|
||||
// All ten imported mesh primitives share these joint entities and
|
||||
// their authored inverse-bind matrices. Bind the joints once and
|
||||
// retain that mesh reference: the stock initialization pose is an
|
||||
// animation pose, not a replacement skin bind pose.
|
||||
return Ok(Some(binding));
|
||||
// Bind each visible modular rig, retaining its authored inverse binds.
|
||||
bindings.extend(binding);
|
||||
}
|
||||
Ok(None)
|
||||
Ok(if bindings.is_empty() { None } else { Some(bindings) })
|
||||
})();
|
||||
match result {
|
||||
Ok(Some(binding)) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -12,3 +12,8 @@ OffBoard clips to the same armature and action bank.
|
||||
|
||||
They decode animation and skeleton files supplied locally by the user. No
|
||||
retail payload is included.
|
||||
|
||||
Local CAC correction: vertex format `0x002C23A5` now decodes as two big-endian
|
||||
float32 values. Its Xenos surface-format field is 37 (32_32_FLOAT); the previous
|
||||
SHORT4N interpretation corrupted secondary tattoo UVs. Source declarations,
|
||||
owned-mesh UV bounds and synthetic decoding tests corroborate the change.
|
||||
+4
-1
@@ -398,7 +398,7 @@ VTX_FORMATS: Dict[int, Tuple[str, int]] = {
|
||||
0x001A215A: ("SHORT4", 8), # raw int16[4] -- position
|
||||
0x001A2286: ("UBYTE4", 4), # raw uint8[4] -- blend indices / weights
|
||||
0x002C2159: ("SHORT2N", 4), # int16[2] / 32768 -- texcoord
|
||||
0x002C23A5: ("SHORT4N", 8), # int16[4] / 32768 -- texcoord pair
|
||||
0x002C23A5: ("FLOAT2", 8), # Xenos format37: two big-endian float32 UVs
|
||||
0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal
|
||||
}
|
||||
|
||||
@@ -514,6 +514,9 @@ def _decode_vertices(d: bytes, vb: int, count: int, vdesc: dict) -> dict:
|
||||
for v in range(count):
|
||||
u, w = struct.unpack_from(">2h", d, vb + v * stride + off)
|
||||
vals.append((u / 32768.0, w / 32768.0))
|
||||
elif fmt == "FLOAT2":
|
||||
for v in range(count):
|
||||
vals.append(struct.unpack_from(">2f", d, vb + v * stride + off))
|
||||
elif fmt == "SHORT4N":
|
||||
for v in range(count):
|
||||
q = struct.unpack_from(">4h", d, vb + v * stride + off)
|
||||
|
||||
Reference in new issue
Block a user