diff --git a/Cargo.lock b/Cargo.lock index 07dccbec..d8b5c6fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4731,7 +4731,9 @@ name = "skate-game" version = "0.1.0" dependencies = [ "bevy", + "blake3", "half", + "image", "serde", "serde_json", "skate-core", @@ -4757,10 +4759,12 @@ dependencies = [ name = "skate-net" version = "0.1.0" dependencies = [ + "blake3", "half", "serde", "serde_json", "skate-core", + "socket2", ] [[package]] @@ -4855,6 +4859,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spade" version = "2.15.1" diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index 9cd5bde7..67f58914 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -33,3 +33,5 @@ skate-mods = { path = "../skate-mods" } skate-vehicles = { path = "../skate-vehicles" } skate-net = { path = "../skate-net" } +blake3 = "=1.8.7" +image = { version = "=0.25.10", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index a103f2ae..24b4adbc 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -16,6 +16,18 @@ struct BoneBinding { parent_bone: Option, } impl AnimationStatus { + pub(crate) fn pose_transforms(&self, pose:&[Mat4])->Vec<(Entity,Transform)> { + self.bindings.iter().filter_map(|b|{ + let global=*pose.get(b.bone)?*render_basis(); + let local=if let Some(parent)=b.parent_bone {(*pose.get(parent)?*render_basis()).inverse()*global} else {global}; + Some((b.entity,Transform::from_matrix(local))) + }).collect() + } + + pub(crate) fn online_bindings(&self) -> Vec<(Entity,usize,Option)> { + self.bindings.iter().map(|b|(b.entity,b.bone,b.parent_bone)).collect() + } + /// Prepare a hidden imported scene without disturbing the live bindings. pub(crate) fn for_scene( root: Entity, @@ -193,6 +205,7 @@ fn bind( } fn present( history: Res, + skater: Res, replay: Res, time: Res>, animation: Res, @@ -205,7 +218,11 @@ fn present( // rotated -90 degrees about X relative to the native frames. Both files' // world positions are Y-up. This is a skin basis change, not a physics turn. let basis = render_basis(); - let Some((previous, current, alpha)) = history.view(&replay, time.overstep_fraction()) else { return; }; + let Some((previous, current, alpha)) = history.view(&replay, time.overstep_fraction()) else { + let pose:Vec<_>=skater.render_pose.iter().copied().map(native_matrix).collect(); + for (entity,transform) in animation.pose_transforms(&pose) {if let Ok(mut node)=nodes.get_mut(entity){*node=transform;}} + return; + }; for binding in &animation.bindings { // Blend bone-local rotations, not matrix entries or independent world // positions: joints retain their hierarchy while limbs turn. @@ -276,3 +293,38 @@ pub(crate) fn network_visual(world: &mut World) -> skate_net::packed::PoseState let basis=render_basis().inverse(); skate_net::packed::PoseState{root:crate::physics::network::pose(root.to_matrix()),bones:anchors.into_iter().map(|i|skate_net::Bone{index:i as u16,pose:crate::physics::network::pose(if locals.contains_key(&i) {global(i,&locals,0)*basis} else {native_matrix(world.resource::().render_pose[i])})}).collect()} } +#[cfg(test)] +mod online_swap_tests { + use super::*; + use bevy::{ecs::system::SystemState,mesh::skinning::SkinnedMesh}; + #[test] + fn online_appearance_swap_seeds_new_rig_and_keeps_animating() { + let mut world=World::new(); + let mut roots=vec![]; + for _ in 0..2 { + let root=world.spawn_empty().id(); + let hip=world.spawn((Name::new("HIPS"),Transform::default(),ChildOf(root))).id(); + let head=world.spawn((Name::new("HEAD"),Transform::default(),ChildOf(hip))).id(); + world.spawn((ChildOf(root),SkinnedMesh{inverse_bindposes:default(),joints:vec![hip,head]})); + roots.push((root,hip,head)); + } + let names=vec!["HIPS".to_owned(),"HEAD".to_owned()]; + let bind=|world:&mut World,root| { + let mut query:SystemState<(Query<(Entity,&SkinnedMesh)>,Query<(&Name,&Transform)>,Query<&ChildOf>)>=SystemState::new(world); + let (skins,nodes,parents)=query.get(world); + AnimationStatus::for_scene(root,&names,&skins,&nodes,&parents).unwrap() + }; + let old=bind(&mut world,roots[0].0); + let pose=[Mat4::from_translation(Vec3::new(1.,2.,3.)),Mat4::from_rotation_z(0.7)]; + for (entity,t) in old.pose_transforms(&pose){*world.get_mut::(entity).unwrap()=t;} + let replacement=bind(&mut world,roots[1].0); + assert_eq!(*world.get::(roots[1].1).unwrap(),Transform::default()); + for (entity,t) in replacement.pose_transforms(&pose){*world.get_mut::(entity).unwrap()=t;} + assert_eq!(world.get::(roots[0].1),world.get::(roots[1].1)); + assert_eq!(world.get::(roots[0].2),world.get::(roots[1].2)); + let next=[Mat4::from_translation(Vec3::new(2.,3.,4.)),Mat4::from_rotation_z(1.2)]; + for (entity,t) in replacement.pose_transforms(&next){*world.get_mut::(entity).unwrap()=t;} + assert_ne!(world.get::(roots[0].1),world.get::(roots[1].1)); + assert_ne!(world.get::(roots[0].2),world.get::(roots[1].2)); + } +} diff --git a/crates/skate-game/src/custom_models.rs b/crates/skate-game/src/custom_models.rs index b90abdbe..89c87f7c 100644 --- a/crates/skate-game/src/custom_models.rs +++ b/crates/skate-game/src/custom_models.rs @@ -101,6 +101,14 @@ pub(crate) struct CustomModels { dirty: bool, } impl CustomModels { + pub(crate) fn online_selection(&self) -> Option<(Option, PathBuf)> { + let e = self.entries.iter().find(|e| Some(&e.id)==self.active.as_ref())?; + let directory = if e.asset_prefix.is_empty() { &self.directory } else { &self.native_directory }; + Some((e.native.as_ref().map(|n|n.key.clone()),directory.join("entries").join(&e.id).join("character.glb"))) + } + pub(crate) fn online_native_path(&self, key: &str) -> Option { + self.entries.iter().find(|e|e.native.as_ref().is_some_and(|n|n.key==key)).map(|e|e.asset_path("character.glb")) + } pub(crate) fn native_style(&self) -> Option<&'static str> { self.active .as_ref() @@ -135,6 +143,8 @@ pub(crate) fn library_path() -> PathBuf { .join("Skate3RustEngine/custom-characters") } pub(crate) fn register_source(app: &mut App) { + let cache = crate::multiplayer::appearance::cache_directory().to_owned(); + app.register_asset_source("online-characters", AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(cache.clone())))); let directory = library_path(); app.register_asset_source( "characters", @@ -590,6 +600,9 @@ fn publish( if let Ok((_, _, mut visibility)) = scenes.get_mut(pending.root) { *visibility = Visibility::Inherited; } + // Seed the new rig before publishing visibility, even when paused. + let pose:Vec<_>=skater.render_pose.iter().copied().map(crate::animation::native_matrix).collect(); + for (joint,transform) in bindings.pose_transforms(&pose) {commands.entity(joint).insert(transform);} *animation = bindings; state.active = Some(pending.id.clone()); state.status = match save_selection(&state.directory, Some(pending.id)) { diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index fb3ac888..1eb151ac 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -110,6 +110,12 @@ impl Parts { } } } + if let Some(colours) = profile["colours"].as_object() { + for value in colours.values() { + let rgb=serde_json::from_value::<[f32;3]>(value.clone()).map_err(|_|"Invalid clothing colour")?; + if rgb.iter().any(|v|!(0.0..=1.0).contains(v)) {return Err("Invalid clothing colour".into());} + } + } for key in ["skin_tint", "hair_tint"] { if let Some(value) = profile.get(key) { let v = serde_json::from_value::<[f32; 3]>(value.clone()) @@ -406,6 +412,45 @@ impl Parts { } Ok(result) } + /// Clone per outfit: remote colours and tattoos must never mutate another player. + pub(crate) fn profile_material(&self, id: &str, mid: &str, profile: &Value, + materials: &Assets) -> Option { + let part = self.library.models.get(id)?; + let source = self.library.materials.get(mid)?; + let mut material = materials.get(&self.materials.get(mid)?.0)?.clone(); + let tint = if !source.flag("SkinTone").is_empty() { profile.get("skin_tint") } + else if part.slot == "Hair" { profile.get("hair_tint") } + else { profile["colours"].get(&part.slot) }; + let rgb = tint.and_then(|v| serde_json::from_value::<[f32;3]>(v.clone()).ok()).unwrap_or(source.tint); + material.base.base_color = Color::srgb(rgb[0],rgb[1],rgb[2]); + let slot = if part.slot == "OuterTorso" && part.flag("TopType").is_empty() { "Arm" } else { &part.slot }; + let stamp = &profile["tattoos"][slot]; + let mut extension = SkinStamp { + hair_opacity: material.extension.hair_opacity.clone(), + retail: material.extension.retail.clone(), retail_mask: material.extension.retail_mask.clone(), + enabled: if source.opacity.is_some() {Vec4::Y} else {Vec4::ZERO}, ..default() + }; + if extension.retail.tint.w > 0. { + extension.retail.tint = Vec4::from_array(material.base.base_color.to_linear().to_f32_array()); + } + if let Some((tid,t)) = stamp["id"].as_str().and_then(|id| self.library.tattoos.get(id).map(|t|(id,t))) { + let key=match stamp["side"].as_u64().unwrap_or(0) {1=>"StampUVConstraintQ4",2=>"StampUVConstraintQ1",3=>"StampUVConstraintQ2",_=>"StampUVConstraintQ3"}; + let bounds:Vec=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.texture=self.tattoos.get(tid).cloned(); extension.transform=transform; + extension.rectangle=rectangle; extension.enabled.x=1.; + } + } + material.extension=extension; Some(material) + } + pub(crate) fn material_ready(&self, mid: &str, server: &AssetServer) -> bool { + self.materials.get(mid).is_some_and(|(_,images)|images.iter().all(|h|server.is_loaded_with_dependencies(h.id()))) + } + pub(crate) fn tattoos_ready(&self, profile: &Value, server: &AssetServer) -> bool { + profile["tattoos"].as_object().into_iter().flat_map(|v|v.values()).all(|v| + v["id"].as_str().is_none_or(|id|self.tattoos.get(id).is_some_and(|h|server.is_loaded_with_dependencies(h.id())))) + } pub fn warm(&mut self, id: &str, server: &AssetServer, materials: &mut Assets) { if self.materials.contains_key(id) { return; @@ -430,16 +475,16 @@ impl Parts { let opacity = m.opacity.as_ref().map(|p| load(p, true)); let retail_mask = m.lighting.as_ref().and_then(|l| l.specular.as_ref()).map(|p| load(p, true)); let retail = m.lighting.as_ref().filter(|l| l.params.len() == 9).map(|l| crate::retail_character::CharacterParams { - tint: Vec4::ONE, - options: Vec4::new(0., f32::from(retail_mask.is_some()), -1., f32::from(l.shader.starts_with("character.hair"))), + tint: Vec4::from_array(Color::srgb(m.tint[0],m.tint[1],m.tint[2]).to_linear().to_f32_array()), + options: Vec4::new(f32::from(normal.is_some()), f32::from(retail_mask.is_some()), -1., f32::from(l.is_hair())), rows: std::array::from_fn(|i| Vec4::from_array(l.params[i])), ..default() }).unwrap_or_default(); 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, + base_color_texture: Some(diffuse.clone()), + normal_map_texture: normal.clone(), metallic_roughness_texture: rough, double_sided: m.opacity.is_some(), cull_mode: if m.opacity.is_some() { @@ -659,82 +704,8 @@ pub(crate) fn update( 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 = 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) { - extension.retail = m.extension.retail.clone(); - extension.retail_mask = m.extension.retail_mask.clone(); - m.extension = extension; - } + if let Some(updated) = parts.profile_material(id, mid, &state.draft, &materials) { + if let Some(material) = materials.get_mut(&handle) { *material = updated; } } for (e, material) in &mut mesh_materials { if !parents.iter_ancestors(e).any(|p| p == entity) { @@ -835,3 +806,75 @@ mod tests { assert!(failures.is_empty(), "{}", failures.join("\n")); } } +#[cfg(test)] +mod online_tests { + use super::*; + #[test] + fn online_appearance_materials_keep_players_colours_and_tattoos_independent() { + let mut parts=Parts::default(); + parts.library.models.insert("body".into(),Part{slot:"Arm".into(),name:"body".into(),flags:HashMap::from([("StampUVConstraintQ3".into(),"0,0,1,1".into())]),materials:vec!["skin".into()],scene:String::new()}); + parts.library.materials.insert("skin".into(),Material{name:"skin".into(),flags:HashMap::from([("SkinTone".into(),"fair".into())]),diffuse:String::new(),normal:None,rough:None,alpha:false,opacity:None,tint:[1.;3],metallic:0.,roughness:0.5,lighting:None}); + parts.library.tattoos.insert("ink".into(),Tattoo{name:"ink".into(),texture:String::new(),bounds:[0.,0.,1.,1.]}); + let mut images=Assets::::default();let image=images.add(Image::default());parts.tattoos.insert("ink".into(),image.clone()); + let mut materials=Assets::::default(); + let handle=materials.add(SkaterMaterial{base:StandardMaterial{normal_map_texture:Some(image.clone()),base_color_texture:Some(image.clone()),..default()},extension:SkinStamp { + retail_mask:Some(image.clone()), + retail:crate::retail_character::CharacterParams{tint:Vec4::ONE,options:Vec4::new(1.,1.,-1.,0.),..default()},..default() + }}); + parts.materials.insert("skin".into(),(handle.clone(),vec![])); + let red=parts.profile_material("body","skin",&serde_json::json!({"skin_tint":[1,0,0],"tattoos":{"Arm":{"id":"ink","side":0}}}),&materials).unwrap(); + let blue=parts.profile_material("body","skin",&serde_json::json!({"skin_tint":[0,0,1]}),&materials).unwrap(); + assert_eq!(red.base.base_color,Color::srgb(1.,0.,0.));assert_eq!(blue.base.base_color,Color::srgb(0.,0.,1.)); + for material in [&red,&blue] { + assert_eq!(material.base.normal_map_texture,Some(image.clone())); + assert_eq!(material.base.base_color_texture,Some(image.clone())); + assert_eq!(material.extension.retail_mask,Some(image.clone())); + assert_eq!(material.extension.retail.options,Vec4::new(1.,1.,-1.,0.)); + assert_eq!(material.extension.retail.tint,Vec4::from_array(material.base.base_color.to_linear().to_f32_array())); + } + assert_eq!(red.extension.texture,Some(image));assert!(blue.extension.texture.is_none()); + assert_eq!(materials.get(&handle).unwrap().base.base_color,Color::WHITE); + assert!(materials.get(&handle).unwrap().extension.texture.is_none()); + } +} + +#[cfg(test)] +mod stock_hair_audit { + use super::*; + #[test] + #[ignore = "requires prepared owned SKATE_CAC_TEST_LIBRARY"] + fn stock_hair_keeps_authored_shading_and_textures_after_colour_restore() { + let path=std::env::var("SKATE_CAC_TEST_LIBRARY").unwrap(); + let library:Library=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let pairs:Vec<_>=library.models.iter().filter(|(_,p)|p.slot=="Hair") + .flat_map(|(id,p)|p.materials.iter().map(move |mid|(id.clone(),mid.clone()))).collect(); + assert!(!pairs.is_empty()); + let mut app=App::new(); + app.add_plugins((MinimalPlugins,AssetPlugin::default())); + app.init_asset::(); + let server=app.world().resource::(); + let mut parts=Parts{library,..default()}; + let mut materials=Assets::::default(); + let mut checked=HashSet::new(); + for (id,mid) in pairs { + parts.warm(&mid,server,&mut materials); + let handle=parts.materials[&mid].0.clone(); + let original=materials.get(&handle).unwrap().clone(); + assert_eq!(original.extension.retail.options.w,1.,"{mid} must use hair lighting"); + let dyed=parts.profile_material(&id,&mid,&serde_json::json!({"hair_tint":[1,0,0]}),&materials).unwrap(); + *materials.get_mut(&handle).unwrap()=dyed; + let restored=parts.profile_material(&id,&mid,&serde_json::json!({}),&materials).unwrap(); + assert_eq!(restored.base.base_color,original.base.base_color); + assert_eq!(restored.extension.retail.tint,original.extension.retail.tint); + assert_eq!(restored.extension.retail.options,original.extension.retail.options); + assert_eq!(restored.base.base_color_texture,original.base.base_color_texture); + assert_eq!(restored.base.normal_map_texture,original.base.normal_map_texture); + assert_eq!(restored.extension.hair_opacity,original.extension.hair_opacity); + assert_eq!(restored.extension.retail_mask,original.extension.retail_mask); + assert_eq!(restored.base.perceptual_roughness,original.base.perceptual_roughness); + *materials.get_mut(&handle).unwrap()=restored; + checked.insert(mid); + } + eprintln!("STOCK_HAIR_AUDIT verified {} authored materials",checked.len()); + } +} diff --git a/crates/skate-game/src/customiser_stamp.wgsl b/crates/skate-game/src/customiser_stamp.wgsl index fa9b6038..45add9b9 100644 --- a/crates/skate-game/src/customiser_stamp.wgsl +++ b/crates/skate-game/src/customiser_stamp.wgsl @@ -23,7 +23,8 @@ struct SkinStamp { pbr_deferred_functions::deferred_output, } #else -#import skate_character_lighting::{CharacterParams, shade_character} +#import skate_character_lighting::{CharacterParams, shade_character, character_normal, character_albedo} +#import bevy_pbr::pbr_bindings @group(#{MATERIAL_BIND_GROUP}) @binding(105) var retail_mask: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(106) var retail_mask_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(107) var retail: CharacterParams; @@ -80,6 +81,17 @@ fn fragment( // generate a PbrInput struct from the StandardMaterial bindings var pbr_input = pbr_input_from_standard_material(in, is_front); +#ifndef PREPASS_PIPELINE + // Preserve authored alpha as specular data before opacity/tattoos/discard. + let retail_albedo=textureSample(pbr_bindings::base_color_texture,pbr_bindings::base_color_sampler,in.uv); + let retail_mapped=character_normal(in.world_position,in.world_normal,in.uv, + textureSample(pbr_bindings::normal_map_texture,pbr_bindings::normal_map_sampler,in.uv).xy); + let retail_n=select(normalize(in.world_normal),retail_mapped,retail.options.x>0.0); + if retail.light.w>0.0 && retail.tint.w>0.0 { + pbr_input.material.base_color=vec4(character_albedo(retail_albedo.rgb)*retail.tint.rgb,retail_albedo.a); + } +#endif + #ifdef VERTEX_UVS_B @@ -113,9 +125,9 @@ fn fragment( var out: FragmentOutput; if retail.light.w > 0.0 && retail.tint.w > 0.0 { let mask = textureSample(retail_mask, retail_mask_sampler, in.uv).r; - out.color = shade_character(retail, pbr_input.N, pbr_input.world_position, + out.color = shade_character(retail, retail_n, pbr_input.world_position, pbr_input.material.base_color.rgb, - select(pbr_input.material.base_color.a, mask, retail.options.y > 0.0), + select(retail_albedo.a, mask, retail.options.y > 0.0), pbr_input.material.base_color.a); } else if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { out.color = apply_pbr_lighting(pbr_input); diff --git a/crates/skate-game/src/multiplayer.rs b/crates/skate-game/src/multiplayer.rs index 533a4d90..ae90b684 100644 --- a/crates/skate-game/src/multiplayer.rs +++ b/crates/skate-game/src/multiplayer.rs @@ -1,5 +1,7 @@ //! Transport-neutral ten-player free-skate; each player owns their simulation. mod render; +pub(crate) mod appearance; +mod appearance_transfer; mod transport; use crate::{ app::{FrameSet, SimulationSet}, @@ -101,9 +103,10 @@ impl Multiplayer { } pub(crate) fn mod_records(&self) -> Vec<(u64, String, u32, Vec)> { self.lobby.as_ref().map_or_else(Vec::new, |l| l.actors.iter().filter(|(id, _)| **id != l.local) - .flat_map(|(&id, actor)| actor.application.iter().map(move |(key,r)| (id,key.clone(),r.seq,r.value.clone()))).collect()) + .flat_map(|(&id, actor)| actor.application.iter().filter(|(key,_)|!key.starts_with("@look/")).map(move |(key,r)| (id,key.clone(),r.seq,r.value.clone()))).collect()) } pub(crate) fn publish_mod(&mut self, key: &str, value: Vec) -> bool { + if key.starts_with("@look/") {return false;} let now = self.started.elapsed().as_millis() as u64; self.lobby.as_mut().is_some_and(|l| l.publish_application(key,value,now)) } @@ -338,6 +341,9 @@ impl Plugin for MultiplayerPlugin { .after(SimulationSet::Controls), ) .add_systems(FixedUpdate, send.after(SimulationSet::Physics)) + .init_resource::() + .add_systems(Last, appearance::cleanup) + .add_systems(Update, appearance::sync.before(render::spawn)) .add_plugins(render::RemoteRenderPlugin); } } @@ -476,9 +482,7 @@ fn receive(mut net: ResMut) { let Some(initial) = body.state.unpack_body() else { continue; }; - let fallback = actor.info.rig != net.info.rig - || actor.info.appearance - != skate_net::hash(skate_net::DEFAULT_APPEARANCE.as_bytes()); + let fallback = actor.info.rig != net.info.rig; info!("MULTIPLAYER_CONNECTED peer={id} fallback={fallback}"); entry.insert(Remote { roots: VecDeque::new(), @@ -555,7 +559,7 @@ fn receive(mut net: ResMut) { lobby.notice.clone() } else if lobby.actors.len() > 1 { format!( - "Connected: {}/10 players | collisions on | default skaters", + "Connected: {}/10 players | collisions on | synced characters", lobby.actors.len() ) } else { @@ -633,15 +637,17 @@ fn hud( physics: Res, mut label: Single<&mut Text, With>, mods: Res, + appearances: Res, ) { ***label = if net.active() { format!( - "{}\n{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer", + "{}\n{}\n{}\n{}\n{}\n{} {}\nPlayer contacts: {} | Esc > Multiplayer", net.status, net.rates, net.provider_metrics, net.visual_status, mods.status, + appearances.progress, appearances.status, physics.network_contacts ) } else { diff --git a/crates/skate-game/src/multiplayer/appearance.rs b/crates/skate-game/src/multiplayer/appearance.rs new file mode 100644 index 00000000..9d60cb4c --- /dev/null +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -0,0 +1,366 @@ +//! Appearance identities reference prepared retail assets; only imported GLBs travel online. +use super::{ + Multiplayer, + appearance_transfer::{Exchange, MAX_BLOB}, +}; +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + sync::OnceLock, +}; +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(super) enum Look { + Stock, + Outfit(Value), + Native(String), + Imported(String), +} +#[derive(Component)] +pub(crate) struct RemoteCharacter; +#[derive(Resource, Default)] +pub(super) struct Appearances { + exchange: Exchange, + connection: Option<(u64, u64)>, + local: String, + pub looks: BTreeMap, + seen: BTreeMap, + cached: BTreeMap<[u8; 32], Look>, + pub status: String, + pub progress: String, +} +pub(super) fn cleanup(mut exit: MessageReader) { + // Generated process-private directory, never the personal import library. + if exit.read().next().is_some() { + let _ = std::fs::remove_dir_all(cache_directory()); + } +} +pub(crate) fn cache_directory() -> &'static Path { + static ROOT: OnceLock = OnceLock::new(); + ROOT.get_or_init(|| std::env::temp_dir().join(format!("skate-online-{:016x}", super::unique()))) +} +pub(super) fn sync( + mut state: ResMut, + mut net: ResMut, + models: Res, + parts: Res, +) { + let Some(lobby) = net.lobby.as_mut() else { + if state.connection.is_some() { + let _ = std::fs::remove_dir_all(cache_directory()); + *state = default(); + } + return; + }; + let connection = (lobby.session, lobby.local); + if state.connection != Some(connection) { + *state = Appearances { + connection: Some(connection), + ..default() + }; + } + let selection = models.online_selection(); + let signature = models + .active + .as_ref() + .map(|id| format!("model:{id}")) + .unwrap_or_else(|| format!("outfit:{}", parts.applied)); + if state.local != signature { + let payload = (|| -> Result, String> { + if let Some((native, path)) = selection { + if let Some(key) = native { + return serde_json::to_vec(&Look::Native(key)).map_err(|e| e.to_string()); + } + if std::fs::metadata(&path).map_err(|e| e.to_string())?.len() as usize >= MAX_BLOB { + return Err("Imported character exceeds the 64 MiB online limit".into()); + } + let glb = std::fs::read(path).map_err(|e| e.to_string())?; + validate_glb(&glb)?; + let mut bytes = vec![0]; + bytes.extend(glb); + Ok(bytes) + } else { + let look = if parts.applied["selections"] + .as_object() + .is_some_and(|s| !s.is_empty()) + { + Look::Outfit(parts.applied.clone()) + } else { + Look::Stock + }; + serde_json::to_vec(&look).map_err(|e| e.to_string()) + } + })(); + match payload { + Ok(bytes) => { + info!( + "ONLINE_CHARACTER_PUBLISH bytes={} selection={signature}", + bytes.len() + ); + state.exchange.publish(bytes); + state.status.clear(); + } + Err(e) => { + state.status = e; + state + .exchange + .publish(serde_json::to_vec(&Look::Stock).unwrap()); + } + } + state.local = signature; + } + let now = net.started.elapsed().as_millis() as u64; + let lobby = net.lobby.as_mut().unwrap(); + state.exchange.tick(lobby, now); + state.progress = state.exchange.progress(); + state.looks.retain(|id, _| lobby.actors.contains_key(id)); + state.seen.retain(|id, _| lobby.actors.contains_key(id)); + let updates: Vec<_> = state + .exchange + .ready + .iter() + .filter(|(id, (key, _))| state.seen.get(id) != Some(&key.hash)) + .map(|(&id, (key, _))| (id, key.hash)) + .collect(); + for (id, hash) in updates { + let bytes = std::mem::take(&mut state.exchange.ready.get_mut(&id).unwrap().1); + state.seen.insert(id, hash); + let result = (|| -> Result { + if bytes.is_empty() { + return state + .cached + .get(&hash) + .cloned() + .ok_or("Cached appearance missing".into()); + } + if bytes.first() == Some(&0) { + validate_glb(&bytes[1..])?; + let name = format!("{}.glb", blake3::Hash::from_bytes(hash).to_hex()); + std::fs::create_dir_all(cache_directory()).map_err(|e| e.to_string())?; + let path = cache_directory().join(&name); + if !path.exists() { + let pending = path.with_extension("tmp"); + std::fs::write(&pending, &bytes[1..]).map_err(|e| e.to_string())?; + std::fs::rename(pending, path).map_err(|e| e.to_string())?; + } + Ok(Look::Imported(format!("online-characters://{name}"))) + } else { + if bytes.len() > 32768 { + return Err("Oversized outfit".into()); + } + let look: Look = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + match &look { + Look::Imported(_) => return Err("Peer supplied an asset path".into()), + Look::Native(key) if key.len() > 96 => { + return Err("Invalid native identity".into()); + } + _ => (), + } + Ok(look) + } + })(); + match result { + Ok(look) => { + let identity = state.exchange.ready[&id].0.clone(); + state.exchange.remember(identity); + state.cached.insert(hash, look.clone()); + info!( + "ONLINE_CHARACTER_READY peer={id} kind={} hash={}", + if matches!(look, Look::Imported(_)) { + "import" + } else { + "retail" + }, + blake3::Hash::from_bytes(hash) + ); + state.looks.insert(id, (hash, look)); + } + Err(e) => warn!("Remote appearance {id}: {e}"), + } + } +} +/// Online imports are self-contained data. Never let peer JSON load a URI or path. +pub(super) fn validate_glb(bytes: &[u8]) -> Result<(), String> { + let bad = || "Invalid or unsupported online character GLB".to_owned(); + let word = |offset: usize| { + bytes + .get(offset..offset + 4) + .and_then(|b| b.try_into().ok()) + .map(u32::from_le_bytes) + }; + if bytes.len() < 28 + || bytes.len() >= MAX_BLOB + || &bytes[..4] != b"glTF" + || word(4) != Some(2) + || word(8) != Some(bytes.len() as u32) + || word(16) != Some(0x4e4f534a) + { + return Err(bad()); + } + let length = word(12).ok_or_else(bad)? as usize; + if length > 8 * 1024 * 1024 { + return Err(bad()); + } + let json: Value = + serde_json::from_slice(bytes.get(20..20 + length).ok_or_else(bad)?).map_err(|_| bad())?; + fn external(v: &Value) -> bool { + match v { + Value::Object(m) => { + m.contains_key("uri") + || m.iter().any(|(k, v)| { + k == "extensionsRequired" && v.as_array().is_some_and(|a| !a.is_empty()) + || external(v) + }) + } + Value::Array(a) => a.iter().any(external), + _ => false, + } + } + if external(&json) { + return Err("Online GLBs must embed all buffers and textures and use standard glTF".into()); + } + let binary = 20 + length; + if word(binary + 4) != Some(0x004e4942) + || word(binary).is_none_or(|n| binary + 8 + n as usize != bytes.len()) + { + return Err(bad()); + } + let bin = &bytes[binary + 8..]; + if json["buffers"].as_array().is_none_or(|a| a.len() != 1) + || json["buffers"][0]["byteLength"] + .as_u64() + .is_none_or(|n| n > bin.len() as u64) + { + return Err(bad()); + } + for (key, max) in [ + ("nodes", 4096), + ("meshes", 256), + ("skins", 128), + ("images", 128), + ("materials", 256), + ("accessors", 8192), + ("bufferViews", 8192), + ] { + if json[key].as_array().is_some_and(|a| a.len() > max) { + return Err(bad()); + } + } + for a in json["accessors"].as_array().into_iter().flatten() { + if a["count"].as_u64().is_none_or(|n| n > 2_000_000) { + return Err(bad()); + } + } + for v in json["bufferViews"].as_array().into_iter().flatten() { + let offset = v["byteOffset"].as_u64().unwrap_or(0); + let size = v["byteLength"].as_u64().ok_or_else(bad)?; + if v["buffer"].as_u64() != Some(0) || offset.saturating_add(size) > bin.len() as u64 { + return Err(bad()); + } + } + let mut pixels = 0u64; + for image in json["images"].as_array().into_iter().flatten() { + let i = image["bufferView"].as_u64().ok_or_else(bad)? as usize; + let view = json["bufferViews"] + .as_array() + .and_then(|a| a.get(i)) + .ok_or_else(bad)?; + let offset = view["byteOffset"].as_u64().unwrap_or(0) as usize; + let size = view["byteLength"].as_u64().ok_or_else(bad)? as usize; + let reader = image::ImageReader::new(std::io::Cursor::new(&bin[offset..offset + size])) + .with_guessed_format() + .map_err(|_| bad())?; + if !matches!( + reader.format(), + Some(image::ImageFormat::Png | image::ImageFormat::Jpeg) + ) { + return Err(bad()); + } + let (w, h) = reader.into_dimensions().map_err(|_| bad())?; + pixels = pixels.saturating_add(w as u64 * h as u64); + if w == 0 || h == 0 || w > 8192 || h > 8192 || pixels > 64 * 1024 * 1024 { + return Err("Online character textures exceed the memory limit".into()); + } + } + let nodes = json["nodes"].as_array().ok_or_else(bad)?; + let mut parents = vec![false; nodes.len()]; + for (i, node) in nodes.iter().enumerate() { + for c in node["children"].as_array().into_iter().flatten() { + let c = c.as_u64().ok_or_else(bad)? as usize; + if c >= nodes.len() || c == i || parents[c] { + return Err(bad()); + } + parents[c] = true; + } + } + fn visit(i: usize, nodes: &[Value], seen: &mut [u8], depth: usize) -> bool { + if depth > 128 || seen[i] == 1 { + return false; + } + if seen[i] == 2 { + return true; + } + seen[i] = 1; + for c in nodes[i]["children"].as_array().into_iter().flatten() { + if !visit(c.as_u64().unwrap() as usize, nodes, seen, depth + 1) { + return false; + } + } + seen[i] = 2; + true + } + let mut seen = vec![0; nodes.len()]; + for i in 0..nodes.len() { + if !visit(i, nodes, &mut seen, 0) { + return Err(bad()); + } + } + if json["skins"].as_array().is_none_or(|s| s.is_empty()) { + return Err("Online character has no skin".into()); + } + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + fn glb(json: Value) -> Vec { + let mut j = serde_json::to_vec(&json).unwrap(); + while j.len() % 4 != 0 { + j.push(b' '); + } + let mut b = b"glTF".to_vec(); + b.extend(2u32.to_le_bytes()); + b.extend((28u32 + j.len() as u32).to_le_bytes()); + b.extend((j.len() as u32).to_le_bytes()); + b.extend(0x4e4f534au32.to_le_bytes()); + b.extend(j); + b.extend(0u32.to_le_bytes()); + b.extend(0x004e4942u32.to_le_bytes()); + b + } + #[test] + fn online_appearance_glb_rejects_paths_cycles_and_bad_ranges() { + let base = serde_json::json!({"asset":{"version":"2.0"},"buffers":[{"byteLength":0}],"nodes":[{}],"skins":[{"joints":[0]}]}); + assert!(validate_glb(&glb(base.clone())).is_ok()); + let mut external = base.clone(); + external["images"] = serde_json::json!([{"uri":"../../private/file.png"}]); + assert!(validate_glb(&glb(external)).is_err()); + let mut cyclic = base.clone(); + cyclic["nodes"] = serde_json::json!([{"children":[1]},{"children":[0]}]); + assert!(validate_glb(&glb(cyclic)).is_err()); + let mut range = base.clone(); + range["bufferViews"] = serde_json::json!([{"buffer":0,"byteLength":99999}]); + assert!(validate_glb(&glb(range)).is_err()); + let mut bytes = glb(base); + bytes[8] = 0; + assert!(validate_glb(&bytes).is_err()); + } + #[test] + #[ignore = "requires an owned imported GLB via SKATE_ONLINE_TEST_GLB"] + fn online_appearance_accepts_owned_import() { + let path = std::env::var("SKATE_ONLINE_TEST_GLB").unwrap(); + validate_glb(&std::fs::read(path).unwrap()).unwrap(); + } +} diff --git a/crates/skate-game/src/multiplayer/appearance_transfer.rs b/crates/skate-game/src/multiplayer/appearance_transfer.rs new file mode 100644 index 00000000..c678a62e --- /dev/null +++ b/crates/skate-game/src/multiplayer/appearance_transfer.rs @@ -0,0 +1,412 @@ +//! Appearance content uses the same authenticated bulk stream on every transport. +pub use skate_net::blob::{Identity, MAX_BLOB}; +use skate_net::lobby::Session; +use std::collections::{BTreeMap, BTreeSet}; +#[derive(Default)] +pub struct Exchange { + pending: Option>, + pub ready: BTreeMap)>, + known: BTreeSet<[u8; 32]>, + progress: (usize, usize), +} +impl Exchange { + pub fn remember(&mut self, id: Identity) { + self.known.insert(id.hash); + } + pub fn publish(&mut self, bytes: Vec) -> bool { + if bytes.is_empty() || bytes.len() > MAX_BLOB { + return false; + } + self.pending = Some(bytes); + true + } + pub fn progress(&self) -> String { + let (done, total) = self.progress; + if total == 0 { + format!("Characters: {} synced", self.ready.len()) + } else { + format!( + "Characters: {} synced | downloading ({}%)", + self.ready.len(), + done * 100 / total + ) + } + } + pub fn tick(&mut self, session: &mut Session, _now: u64) { + if let Some(bytes) = self.pending.take() { + session.blobs.publish(session.local, bytes); + } + self.ready.retain(|id, _| session.actors.contains_key(id)); + for &actor in session.actors.keys().filter(|id| **id != session.local) { + let Some((id, bytes)) = session.blobs.ready(actor) else { + continue; + }; + if self.ready.get(&actor).is_some_and(|(old, _)| old == id) { + continue; + } + let bytes = if self.known.contains(&id.hash) { + vec![] + } else { + bytes.to_vec() + }; + self.ready.insert(actor, (id.clone(), bytes)); + } + self.progress = session.blobs.progress(session.local); + } +} +/// Exercise actual sockets, host forwarding and a receiver with an empty cache. +/// Also used by the owned-asset test before loading the received GLB in Bevy. +#[cfg(test)] +pub(super) fn transfer_over_udp(model: Vec) -> Vec { + use std::{ + net::UdpSocket, + time::{Duration, Instant}, + }; + let sockets: Vec<_> = (0..3) + .map(|_| { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + skate_net::socket::configure(&socket).unwrap(); + socket + }) + .collect(); + let addresses: Vec<_> = sockets.iter().map(|s| s.local_addr().unwrap()).collect(); + let mut sessions: Vec<_> = (0..3) + .map(|i| { + Session::new( + 91, + skate_net::lobby::Info { + id: 10 + i, + map: 1, + rig: 1, + physics: 1, + appearance: 1, + }, + if i == 0 { None } else { Some(1) }, + ) + }) + .collect(); + let mut exchanges: Vec<_> = (0..3).map(|_| Exchange::default()).collect(); + for e in &mut exchanges { + e.publish(vec![5]); + } + exchanges[1].publish(model.clone()); + let started = Instant::now(); + let mut frame = 0; + let mut buffer = [0u8; 1500]; + let mut result = None; + let mut completed_at = None; + let mut last_payload = 0; + loop { + let now = started.elapsed().as_millis() as u64; + assert!( + now < 25000, + "actual UDP transfer stalled: {}", + exchanges[2].progress() + ); + for i in 0..3 { + for _ in 0..4096 { + match sockets[i].recv_from(&mut buffer) { + Ok((n, from)) => { + let peer = addresses.iter().position(|a| *a == from).unwrap() + 1; + sessions[i].receive(peer as u64, &buffer[..n], now); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(e) => panic!("UDP receive: {e}"), + } + } + let pose = skate_net::Pose { + p: [frame as f32 * 0.001, 0., 0.], + q: [0., 0., 0., 1.], + }; + let body = skate_net::Body { + pose, + velocity: [0.; 3], + angular: [0.; 3], + }; + sessions[i].publish( + skate_net::packed::BODY, + skate_net::packed::Packed::body(&skate_net::packed::BodyState { + root: pose, + enabled: (1u64 << 33) - 1, + bodies: vec![body; 33], + }) + .unwrap(), + now, + ); + exchanges[i].tick(&mut sessions[i], now); + for p in sessions[i].service(now) { + if p.data[24] == skate_net::blob::DATA { + last_payload = now; + } + sockets[i] + .send_to(&p.data, addresses[p.peer as usize - 1]) + .unwrap(); + } + } + if result.is_none() + && exchanges[2] + .ready + .get(&11) + .is_some_and(|(_, b)| b == &model) + { + eprintln!( + "ONLINE_UDP_COMPLETE bytes={} elapsed_ms={now} fps=15 route=client-host-client", + model.len() + ); + assert!( + now < 15000, + "32 MB should not take minutes even at 15 FPS: {now} ms" + ); + result = Some(std::mem::take( + &mut exchanges[2].ready.get_mut(&11).unwrap().1, + )); + completed_at = Some(now); + } + if completed_at.is_some_and(|at| now - at > 1500) { + assert!( + sessions[2].actors[&11] + .body + .latest() + .is_some_and(|b| now - b.received < 300), + "model upload starved the remote player's movement" + ); + assert!( + last_payload < completed_at.unwrap() + 500, + "completed upload keeps sending data" + ); + return result.unwrap(); + } + frame += 1; + if let Some(wait) = Duration::from_millis(frame * 67).checked_sub(started.elapsed()) { + std::thread::sleep(wait); + } + } +} +#[cfg(test)] +#[test] +fn online_appearance_real_udp_large_model_at_low_frame_rate() { + transfer_over_udp((0..32_067_793).map(|i| (i * 17) as u8).collect()); +} +#[cfg(test)] +mod tests { + use super::*; + use skate_net::lobby::Info; + #[test] + fn online_appearance_transfer_survives_loss_reordering_late_join_and_changes() { + let mut sessions: Vec<_> = (0..10) + .map(|i| { + Session::new( + 42, + Info { + id: 100 + i, + map: 1, + rig: 2, + physics: 3, + appearance: 4, + }, + if i == 0 { None } else { Some(1) }, + ) + }) + .collect(); + let mut exchanges: Vec<_> = (0..10).map(|_| Exchange::default()).collect(); + let mut expected: Vec<_> = (0..10) + .map(|i| { + (0..(90000 + i * 1000)) + .map(|j| (j * 17 + i) as u8) + .collect::>() + }) + .collect(); + for (e, b) in exchanges.iter_mut().zip(&expected) { + assert!(e.publish(b.clone())); + } + let mut completed = false; + let mut delayed = vec![]; + for step in 0..16000 { + let now = step * 25; + let active = if step < 200 { 3 } else { 10 }; + if step == 500 { + expected[1] = vec![77; 123456]; + exchanges[1].publish(expected[1].clone()); + } + let mut wire = vec![]; + for i in 0..active { + if step % 4 == 0 { + let pose = skate_net::Pose { + p: [step as f32 * 0.001, i as f32, 0.], + q: [0., 0., 0., 1.], + }; + let body = skate_net::Body { + pose, + velocity: [0.; 3], + angular: [0.; 3], + }; + let mut packed = + skate_net::packed::Packed::body(&skate_net::packed::BodyState { + root: pose, + enabled: (1u64 << 33) - 1, + bodies: vec![body; 33], + }) + .unwrap(); + packed.captured = now; + sessions[i].publish(skate_net::packed::BODY, packed, now); + } + exchanges[i].tick(&mut sessions[i], now); + for p in sessions[i].service(now) { + if (step + i as u64 + p.data.len() as u64) % 7 != 0 { + wire.push((i + 1, p)); + } + } + } + wire.reverse(); + for (from, p) in wire { + let due = now + 50 + ((from as u64 * 17 + step * 13) % 70); + delayed.push((due, from, p)); + } + let (arrived, pending): (Vec<_>, Vec<_>) = + delayed.into_iter().partition(|(due, _, _)| *due <= now); + delayed = pending; + for (_, from, p) in arrived { + let to = p.peer as usize - 1; + if to < active { + sessions[to].receive(from as u64, &p.data, now); + if step % 13 == 0 { + sessions[to].receive(from as u64, &p.data, now); + } + } + } + if step > 500 + && exchanges.iter().enumerate().all(|(i, e)| { + expected.iter().enumerate().all(|(j, b)| { + i == j || e.ready.get(&(100 + j as u64)).is_some_and(|(_, v)| v == b) + }) + }) + { + for session in &sessions { + for (&id, actor) in &session.actors { + if id != session.local { + assert!( + actor.body.latest().is_some_and(|b| now - b.received < 1000), + "appearance transfer must not starve movement" + ); + } + } + } + completed = true; + break; + } + } + assert!( + completed, + "every player must receive the latest complete appearance" + ); + for session in &sessions { + for actor in session.actors.values() { + assert!(actor.application.len() <= 43); + } + } + } + #[test] + fn online_appearance_rejects_unbounded_metadata_and_chunks() { + let id = Identity { + hash: [1; 32], + size: MAX_BLOB + 1, + }; + assert!(id.size > MAX_BLOB); + let mut e = Exchange::default(); + assert!(!e.publish(vec![])); + } +} +#[cfg(test)] +mod delivery_tests { + use super::*; + #[test] + fn online_appearance_large_model_delivered_once_cached_and_quiet_after_swap_back() { + let mut sessions: Vec<_> = (0..2) + .map(|i| { + Session::new( + 44, + skate_net::lobby::Info { + id: 10 + i, + map: 1, + rig: 1, + physics: 1, + appearance: 1, + }, + if i == 0 { None } else { Some(1) }, + ) + }) + .collect(); + + let mut exchanges = [Exchange::default(), Exchange::default()]; + let model: Vec = (0..32_067_793).map(|i| (i * 17) as u8).collect(); + exchanges[0].publish(model.clone()); + exchanges[1].publish(vec![4]); + let mut phase = 0; + let mut finished_at = 0; + let mut last_model_packet = 0; + let mut reused = false; + for step in 0..18000 { + let now = step * 67; + let mut wire = vec![]; + for i in 0..2 { + exchanges[i].tick(&mut sessions[i], now); + for p in sessions[i].service(now) { + if p.data.get(24) == Some(&skate_net::blob::DATA) && p.data.len() > 100 { + last_model_packet = now; + assert!(phase < 3, "completed model was retransmitted"); + } + if step % 31 != 0 { + wire.push((i + 1, p)); + } + } + } + for (from, p) in wire { + sessions[p.peer as usize - 1].receive(from as u64, &p.data, now); + } + if phase == 0 + && exchanges[1] + .ready + .get(&10) + .is_some_and(|(_, b)| b == &model) + { + assert!( + now < 15000, + "network model transfer at 15 FPS is too slow: {now} ms" + ); + let identity = exchanges[1].ready[&10].0.clone(); + exchanges[1].remember(identity); + exchanges[1].ready.get_mut(&10).unwrap().1.clear(); + exchanges[0].publish(vec![9]); + phase = 1; + } + if phase == 1 + && exchanges[1] + .ready + .get(&10) + .is_some_and(|(_, b)| b == &vec![9]) + { + exchanges[0].publish(model.clone()); + phase = 2; + finished_at = now; + } + if phase == 2 + && exchanges[1] + .ready + .get(&10) + .is_some_and(|(id, b)| id.size == model.len() && b.is_empty()) + { + reused = true; + if now - finished_at > 2000 { + phase = 3; + finished_at = now; + } + } + if phase == 3 && now - finished_at > 5000 { + assert!(last_model_packet < finished_at); + break; + } + } + assert_eq!(phase, 3); + assert!(reused); + } +} diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index 701b9c1d..16cb074a 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -1,8 +1,25 @@ +use super::appearance::{Appearances, Look, RemoteCharacter}; use super::*; -use bevy::mesh::skinning::SkinnedMesh; +use crate::customiser_material::SkaterMaterial; +use bevy::{ + mesh::{morph::MorphWeights, skinning::SkinnedMesh}, + scene::SceneInstance, +}; +struct Candidate { + root: Entity, + scenes: Vec, + look: Look, + started: Instant, +} +#[derive(Component)] +struct OutfitPiece { + id: String, + mid: String, +} + use skate_net::interpolation::{Buffer, Clock, position}; #[derive(Resource)] -struct RemoteSkins { +pub(super) struct RemoteSkins { reported: f64, actors: BTreeMap, rest: Vec, @@ -13,6 +30,10 @@ struct RemoteSkins { struct RemoteSkin { root: Option, bindings: Vec<(Entity, usize, Option)>, + visible: Option, + pending: Option, + requested: Option<[u8; 32]>, + positions: Buffer<[f32; 3]>, roots: Buffer, poses: Buffer>, @@ -59,7 +80,13 @@ impl Plugin for RemoteRenderPlugin { .iter() .map(|&p| p as i32) .collect(); - let board=skater.animation.evaluator.frames.bone_names.iter().position(|n|n=="SKATEBOARD_ROOT"); + let board = skater + .animation + .evaluator + .frames + .bone_names + .iter() + .position(|n| n == "SKATEBOARD_ROOT"); app.insert_resource(RemoteSkins { board, reported: 0., @@ -69,15 +96,20 @@ impl Plugin for RemoteRenderPlugin { }) .add_systems( Update, - (spawn, bind, present).chain().after(crate::modding::vehicles::present), + (spawn, bind, present) + .chain() + .after(crate::modding::vehicles::present), ); } } -fn spawn( +pub(super) fn spawn( mut commands: Commands, net: Res, + looks: Res, mut skins: ResMut, server: Res, + parts: Res, + models: Res, ) { let removed: Vec<_> = skins .actors @@ -90,77 +122,262 @@ fn spawn( commands.entity(root).despawn(); } } - for (&id, _) in &net.remotes { + for &id in net.remotes.keys() { let skin = skins.actors.entry(id).or_insert_with(|| RemoteSkin { clock: Clock::for_connection(net.loopback), ..default() }); - if skin.root.is_some() { - continue; - } - skin.root = Some( + let root = *skin.root.get_or_insert_with(|| { commands .spawn(( + RemoteCharacter, Transform::default(), - Visibility::default(), - Name::new("Remote default skater"), + Visibility::Inherited, + Name::new("Remote skater"), )) - .with_children(|root| { - root.spawn(SceneRoot( - server.load(GltfAssetLabel::Scene(0).from_asset("private/skater.glb")), - )); - }) - .id(), - ); + .id() + }); + let compatible = net + .lobby + .as_ref() + .and_then(|l| l.actors.get(&id)) + .is_some_and(|a| a.info.rig == net.info.rig); + let (key, look) = if skin.visible.is_some() && compatible { + looks + .looks + .get(&id) + .cloned() + .unwrap_or(([0; 32], Look::Stock)) + } else { + ([0; 32], Look::Stock) + }; + if skin.requested == Some(key) { + continue; + } + skin.requested = Some(key); + if let Some(old) = skin.pending.take() { + commands.entity(old.root).despawn(); + } + let mut scenes = vec![]; + let mut specs = vec![]; + let look = match look { + Look::Outfit(profile) => match parts.resolve(&profile) { + Ok(p) => Look::Outfit(p), + Err(e) => { + warn!("Remote outfit: {e}"); + Look::Stock + } + }, + other => other, + }; + match &look { + Look::Stock => specs.push(("private/skater.glb".to_owned(), None)), + Look::Native(key) => { + if let Some(path) = models.online_native_path(key) { + specs.push((path, None)); + } else { + specs.push(("private/skater.glb".to_owned(), None)); + } + } + Look::Imported(path) => specs.push((path.clone(), None)), + Look::Outfit(profile) => { + for v in profile["selections"] + .as_object() + .into_iter() + .flat_map(|s| s.values()) + { + let Some((id, mid)) = v["asset_id"].as_str().zip(v["material_id"].as_str()) + else { + continue; + }; + if let Some(part) = parts.library.models.get(id) { + specs.push((part.scene.clone(), Some((id.to_owned(), mid.to_owned())))); + } + } + } + } + if specs.is_empty() || specs.len() > 32 { + continue; + } + let candidate = commands + .spawn((Transform::default(), Visibility::Hidden, ChildOf(root))) + .id(); + if let Look::Native(key) = &look { + commands + .entity(candidate) + .insert(crate::custom_models::NativeModelRoot(key.clone())); + } + if matches!(look, Look::Imported(_)) { + commands + .entity(candidate) + .insert(crate::custom_models::CustomModelRoot); + } + for (path, piece) in specs { + let mut e = commands.spawn(( + SceneRoot(server.load(GltfAssetLabel::Scene(0).from_asset(path))), + ChildOf(candidate), + )); + if let Some((id, mid)) = piece { + e.insert(( + crate::customiser_parts::PartRoot(id.clone()), + OutfitPiece { id, mid }, + )); + } + scenes.push(e.id()); + } + skin.pending = Some(Candidate { + root: candidate, + scenes, + look, + started: Instant::now(), + }); } } fn bind( + mut commands: Commands, + net: Res, mut skins: ResMut, skater: Res, meshes: Query<(Entity, &SkinnedMesh)>, - names: Query<&Name>, + nodes: Query<(&Name, &Transform)>, parents: Query<&ChildOf>, + instances: Query<&SceneInstance>, + spawner: Res, + server: Res, + mut parts: ResMut, + mut materials: ResMut>, + pieces: Query<&OutfitPiece>, + mut morphs: Query<(Entity, &mut MorphWeights)>, ) { - for skin in skins.actors.values_mut() { - let Some(root) = skin.root else { + let initial_poses: BTreeMap<_, _> = skins + .actors + .iter() + .filter(|(_,skin)|skin.pending.is_some()) + .map(|(&id,_)| { + let bones = net + .remotes + .get(&id) + .and_then(|r| r.poses.back()) + .map(|p| p.bones.as_slice()) + .unwrap_or(&[]); + (id, globals(bones, &skins)) + }) + .collect(); + for (id, skin) in skins.actors.iter_mut() { + let Some(p) = skin.pending.as_ref() else { continue; }; - if !skin.bindings.is_empty() { + if p.started.elapsed() > Duration::from_secs(120) { + warn!("Remote appearance loading timed out; previous skater retained"); + commands.entity(p.root).despawn(); + skin.pending = None; continue; } - let bone_names = &skater.animation.evaluator.frames.bone_names; - for (entity, mesh) in &meshes { - if !parents.iter_ancestors(entity).any(|p| p == root) { + if p.scenes.iter().any(|&e| { + !instances + .get(e) + .is_ok_and(|i| spawner.instance_is_ready(**i)) + }) { + continue; + } + if let Look::Outfit(profile) = &p.look { + for e in &p.scenes { + if let Ok(piece) = pieces.get(*e) { + parts.warm(&piece.mid, &server, &mut materials); + } + } + if !parts.tattoos_ready(profile, &server) + || p.scenes.iter().any(|&e| { + pieces + .get(e) + .is_ok_and(|piece| !parts.material_ready(&piece.mid, &server)) + }) + { continue; } - let mut bindings = vec![]; - for &joint in &mesh.joints { - let Ok(name) = names.get(joint) else { - return; - }; - let Some(bone) = bone_names - .iter() - .position(|b| b.eq_ignore_ascii_case(name.as_str())) - else { - return; - }; - let parent = parents - .iter_ancestors(joint) - .take_while(|&p| p != root) - .find_map(|p| { - names.get(p).ok().and_then(|n| { - bone_names - .iter() - .position(|b| b.eq_ignore_ascii_case(n.as_str())) - }) - }); - bindings.push((joint, bone, parent)); - } - skin.bindings = bindings; - skin.poses = Buffer::default(); - break; } + let bindings = match crate::animation::AnimationStatus::for_scene( + p.root, + &skater.animation.evaluator.frames.bone_names, + &meshes, + &nodes, + &parents, + ) { + Ok(b) => b.online_bindings(), + Err(e) => { + warn!("Remote appearance rejected: {e}"); + commands.entity(p.root).despawn(); + skin.pending = None; + continue; + } + }; + if let Look::Outfit(profile) = &p.look { + for &scene in &p.scenes { + let Ok(piece) = pieces.get(scene) else { + continue; + }; + let Some(material) = + parts.profile_material(&piece.id, &piece.mid, profile, &materials) + else { + continue; + }; + let handle = materials.add(material); + for (e, _) in &meshes { + if parents.iter_ancestors(e).any(|p| p == scene) { + commands + .entity(e) + .remove::>() + .insert(MeshMaterial3d(handle.clone())); + } + } + } + let weights: Vec = parts + .library + .morphs + .iter() + .enumerate() + .map(|(i, n)| { + profile["morphs"][n] + .as_f64() + .unwrap_or(if (2..19).contains(&i) { 0.25 } else { 0. }) + .clamp(0., 0.5) as f32 + }) + .collect(); + for (e, mut m) in &mut morphs { + if parents.iter_ancestors(e).any(|e| e == p.root) + && m.weights().len() == weights.len() + { + m.weights_mut().copy_from_slice(&weights); + } + } + } + for (e, _) in &meshes { + if parents.iter_ancestors(e).any(|e| e == p.root) { + commands.entity(e).insert(( + bevy::camera::visibility::NoFrustumCulling, + bevy::camera::visibility::RenderLayers::from_layers(&[0, 28]), + )); + } + } + let pose = &initial_poses[id]; + let basis = Mat4::from_cols(Vec4::X, -Vec4::Z, Vec4::Y, Vec4::W); + for &(entity, bone, parent) in &bindings { + let global = pose[bone] * basis; + let local = parent.map_or(global, |parent| (pose[parent] * basis).inverse() * global); + commands + .entity(entity) + .insert(Transform::from_matrix(local)); + } + if let Some(old) = skin.visible.replace(p.root) { + commands.entity(old).despawn(); + } + commands.entity(p.root).insert(Visibility::Inherited); + info!("ONLINE_CHARACTER_VISIBLE peer={id} joints={} kind={}", bindings.len(), + if matches!(p.look, Look::Imported(_)) { "import" } else { "retail" }); + skin.bindings = bindings; + skin.poses = Buffer::default(); + skin.pending = None; } } @@ -276,11 +493,22 @@ fn present( } } } - if let Some(root)=skin.root { - if let Some(attached)=crate::modding::vehicles::network::attached_root(&vehicles,id) {if let Ok(mut t)=nodes.get_mut(root) {*t=attached;}} + if let Some(root) = skin.root { + if let Some(attached) = crate::modding::vehicles::network::attached_root(&vehicles, id) + { + if let Ok(mut t) = nodes.get_mut(root) { + *t = attached; + } + } + } + let seated = remote.body.enabled & (1u64 << 62) != 0; + for &(entity, i, _) in &skin.bindings { + if skins.board == Some(i) { + if let Ok(mut t) = nodes.get_mut(entity) { + t.scale = Vec3::splat(if seated { 0.001 } else { 1. }); + } + } } - let seated=remote.body.enabled & (1u64<<62)!=0; - for &(entity,i,_) in &skin.bindings {if skins.board==Some(i) {if let Ok(mut t)=nodes.get_mut(entity) {t.scale=Vec3::splat(if seated {0.001} else {1.});}}} skins.actors.insert(id, skin); } if now < skins.reported || now - skins.reported >= 1. { @@ -301,3 +529,183 @@ fn present( skins.reported = now; } } + +#[cfg(test)] +mod online_owned_tests { + use super::*; + #[test] + #[ignore = "requires SKATE3_ASSET_ROOT prepared owned assets"] + fn online_appearance_owned_male_and_female_outfits_bind_every_clothing_rig() { + use bevy::{ + asset::AssetPlugin, ecs::system::SystemState, gltf::GltfPlugin, image::ImagePlugin, + mesh::MeshPlugin, scene::ScenePlugin, + }; + let assets = std::path::PathBuf::from(std::env::var("SKATE3_ASSET_ROOT").unwrap()); + let banks = skate_data::animation_banks::AnimationBanks::load(&assets).unwrap(); + let names = skate_data::animation_frames::AnimationFrames::from_banks(&banks) + .unwrap() + .bone_names; + let library: crate::customiser_parts::Library = serde_json::from_slice( + &std::fs::read( + crate::customiser_parts::asset_directory(&assets).join("library-v3.json"), + ) + .unwrap(), + ) + .unwrap(); + let parts = crate::customiser_parts::Parts::for_test(library); + let mut app = App::new(); + crate::custom_models::register_source(&mut app); + app.add_plugins(( + MinimalPlugins, + AssetPlugin { + file_path: assets.to_string_lossy().into_owned(), + ..default() + }, + ImagePlugin::default(), + MeshPlugin, + ScenePlugin, + GltfPlugin::default(), + )); + app.init_asset::() + .init_asset::() + .register_type::>(); + app.finish(); + app.cleanup(); + if let Ok(path) = std::env::var("SKATE_ONLINE_TEST_GLB") { + let bytes = std::fs::read(path).unwrap(); + super::super::appearance::validate_glb(&bytes).unwrap(); + let cache = super::super::appearance::cache_directory(); + std::fs::create_dir_all(cache).unwrap(); + let mut payload = vec![0]; + payload.extend(bytes); + let received = super::super::appearance_transfer::transfer_over_udp(payload); + assert_eq!(received[0], 0); + super::super::appearance::validate_glb(&received[1..]).unwrap(); + std::fs::write(cache.join("received-test.glb"), &received[1..]).unwrap(); + let handle = app + .world() + .resource::() + .load(GltfAssetLabel::Scene(0).from_asset("online-characters://received-test.glb")); + let root = app + .world_mut() + .spawn((SceneRoot(handle), Transform::default(), Visibility::Hidden)) + .id(); + let started = Instant::now(); + loop { + app.update(); + let w = app.world(); + if w.get::(root) + .is_some_and(|i| w.resource::().instance_is_ready(**i)) + { + break; + } + assert!( + started.elapsed().as_secs() < 45, + "received online import failed to load" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let mut queries: SystemState<( + Query<(Entity, &SkinnedMesh)>, + Query<(&Name, &Transform)>, + Query<&ChildOf>, + )> = SystemState::new(app.world_mut()); + let (meshes, nodes, parents) = queries.get(app.world()); + let animation = crate::animation::AnimationStatus::for_scene( + root, &names, &meshes, &nodes, &parents + ).unwrap(); + let joints=animation.online_bindings(); + assert!(!joints.is_empty()); + // A received model must accept poses before display, then keep moving. + let first:Vec<_>=(0..names.len()).map(|i|Mat4::from_translation(Vec3::new(i as f32*0.01,1.,0.))).collect(); + for (joint,pose) in animation.pose_transforms(&first) { + assert!(pose.to_matrix().is_finite()); + *app.world_mut().get_mut::(joint).unwrap()=pose; + } + *app.world_mut().get_mut::(root).unwrap()=Visibility::Inherited; + let second:Vec<_>=first.iter().enumerate().map(|(i,m)|*m*Mat4::from_rotation_z(0.01*(i+1) as f32)).collect(); + for (joint,pose) in animation.pose_transforms(&second) { + assert!(pose.to_matrix().is_finite()); + assert_ne!(*app.world().get::(joint).unwrap(),pose); + *app.world_mut().get_mut::(joint).unwrap()=pose; + } + app.world_mut().entity_mut(root).despawn(); + app.update(); + std::fs::remove_file(cache.join("received-test.glb")).unwrap(); + } + for gender in ["male", "female"] { + let profile = parts.resolve(&parts.library.defaults[gender]).unwrap(); + let candidate = app + .world_mut() + .spawn((Transform::default(), Visibility::Hidden)) + .id(); + let paths: Vec<_> = profile["selections"] + .as_object() + .unwrap() + .values() + .map(|v| { + parts.library.models[v["asset_id"].as_str().unwrap()] + .scene + .clone() + }) + .collect(); + assert!(paths.len() > 3); + let mut scenes = vec![]; + for path in paths { + let handle = app + .world() + .resource::() + .load(GltfAssetLabel::Scene(0).from_asset(path)); + scenes.push( + app.world_mut() + .spawn((SceneRoot(handle), ChildOf(candidate))) + .id(), + ); + } + let started = Instant::now(); + loop { + app.update(); + let world = app.world(); + if scenes.iter().all(|&e| { + world + .get::(e) + .is_some_and(|i| world.resource::().instance_is_ready(**i)) + }) { + break; + } + assert!( + started.elapsed().as_secs() < 45, + "{gender} scene loading timed out" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let mut queries: SystemState<( + Query<(Entity, &SkinnedMesh)>, + Query<(&Name, &Transform)>, + Query<&ChildOf>, + )> = SystemState::new(app.world_mut()); + let (meshes, nodes, parents) = queries.get(app.world()); + let binding = crate::animation::AnimationStatus::for_scene( + candidate, &names, &meshes, &nodes, &parents, + ) + .unwrap() + .online_bindings(); + let expected: std::collections::HashSet<_> = meshes + .iter() + .filter(|(e, _)| parents.iter_ancestors(*e).any(|e| e == candidate)) + .flat_map(|(_, m)| m.joints.iter().copied()) + .collect(); + assert_eq!(binding.len(), expected.len()); + for scene in scenes { + assert!( + binding + .iter() + .any(|(e, _, _)| parents.iter_ancestors(*e).any(|e| e == scene)), + "all outfit scene rigs must animate" + ); + } + app.world_mut().entity_mut(candidate).despawn(); + app.update(); + } + } +} diff --git a/crates/skate-game/src/multiplayer/transport.rs b/crates/skate-game/src/multiplayer/transport.rs index a18f3f92..e1a3d5ea 100644 --- a/crates/skate-game/src/multiplayer/transport.rs +++ b/crates/skate-game/src/multiplayer/transport.rs @@ -40,7 +40,7 @@ impl Direct { pub fn new(bind: SocketAddr) -> io::Result { endpoint(bind)?; let socket = UdpSocket::bind(bind)?; - socket.set_nonblocking(true)?; + skate_net::socket::configure(&socket)?; Ok(Self { socket }) } } @@ -56,7 +56,7 @@ impl Transport for Direct { fn receive(&mut self) -> io::Result)>> { let mut output = Vec::new(); let mut buffer = [0; 1500]; - for _ in 0..256 { + for _ in 0..4096 { match self.socket.recv_from(&mut buffer) { Ok((n, from)) if n <= skate_net::packed::MTU => { output.push((endpoint(from)?, buffer[..n].to_vec())) @@ -99,7 +99,7 @@ pub(super) struct Steam { impl Steam { pub fn new(peer: u64, session: u64) -> Result { let socket = UdpSocket::bind("127.0.0.1:0").map_err(|e| e.to_string())?; - socket.set_nonblocking(true).map_err(|e| e.to_string())?; + skate_net::socket::configure(&socket).map_err(|e| e.to_string())?; let dir = std::env::current_exe() .map_err(|e| e.to_string())? .parent() @@ -200,7 +200,7 @@ impl Transport for Steam { let mut packets = vec![]; let mut buffer = [0; 1500]; let prefix = format!("SK8RELAY {} ", self.cookie); - for _ in 0..256 { + for _ in 0..4096 { match self.socket.recv_from(&mut buffer) { Ok((n, from)) => { if !from.ip().is_loopback() || self.peer.is_some_and(|p| p != from) { diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 2d8476b8..2d2b2414 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -31,6 +31,13 @@ pub(crate) struct MaterialData { pub params: Vec<[f32; 4]>, pub specular: Option, } +impl MaterialData { + pub(crate) fn is_hair(&self) -> bool { + matches!(self.shader.as_str(), "character.hair" | "character.hair_ropa" + | "character.default_hair" | "character.default_hair_ropa") + } +} + #[derive(Resource)] struct CharacterShader(#[allow(dead_code)] Handle); #[derive(Deserialize)] @@ -235,9 +242,10 @@ fn bind( mut materials: ResMut>, entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d, Option<&RenderLayers>)>, parents: Query<&ChildOf>, - players: Query<(), With>, + players: Query<(), Or<(With, With)>>, parts: Query<(), With>, native: Query<&crate::custom_models::NativeModelRoot>, + imports: Query<(), With>, ) { let Some(lighting) = lighting else { return; @@ -248,6 +256,7 @@ fn bind( // Giving them a second material races publication and can render twice. if parents.iter_ancestors(entity).any(|e| parts.contains(e)) { continue; } let native_key = parents.iter_ancestors(entity).find_map(|e| native.get(e).ok().map(|n| &n.0)); + if native_key.is_none() && parents.iter_ancestors(entity).any(|e|imports.contains(e)) {continue;} let table = if let Some(key) = native_key { lighting.data.native.get(key) } else { Some(&lighting.data.materials) }; @@ -273,7 +282,7 @@ fn bind( f32::from(m.normal_map_texture.is_some()), f32::from(data.specular.is_some()), alpha_cutoff, - f32::from(data.shader.starts_with("character.hair")), + f32::from(data.is_hair()), ), rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), sh: lighting @@ -356,6 +365,15 @@ fn update( mod tests { use super::*; #[test] + fn customiser_hair_families_include_pro_skater_defaults() { + for shader in ["character.hair", "character.hair_ropa", "character.default_hair", "character.default_hair_ropa"] { + assert!(MaterialData {shader:shader.into(),params:vec![],specular:None}.is_hair(), "{shader}"); + } + for shader in ["character.default_skin", "character.default_cloth", "character.default_cloth_ropa"] { + assert!(!MaterialData {shader:shader.into(),params:vec![],specular:None}.is_hair(), "{shader}"); + } + } + #[test] fn customiser_material_binding_uses_native_rows_and_leaves_cac_material_owned() { use bevy::ecs::system::RunSystemOnce; let mut app = App::new(); @@ -374,7 +392,14 @@ mod tests { let torso = world.spawn((ChildOf(pro), GltfMaterialName("Retail_Torso".into()), MeshMaterial3d(material.clone()))).id(); let part = world.spawn((crate::customiser_parts::PartRoot("shirt".into()), ChildOf(player))).id(); let cloth = world.spawn((ChildOf(part), GltfMaterialName("Retail_Torso".into()), MeshMaterial3d(material))).id(); + let hair_data=MaterialData {shader:"character.default_hair".into(),params:vec![[1.;4];9],specular:None}; + world.resource_mut::().data.native.get_mut("pro").unwrap().insert("Retail_Hair".into(),hair_data); + let hair_material=world.resource_mut::>().add(StandardMaterial::default()); + let hair=world.spawn((ChildOf(pro),GltfMaterialName("Retail_Hair".into()),MeshMaterial3d(hair_material))).id(); world.run_system_once(bind).unwrap(); + let hair_handle=&world.get::>(hair).unwrap().0; + assert_eq!(world.resource::>().get(hair_handle).unwrap().params.options.w,1.); + let h = &world.get::>(torso).unwrap().0; assert_eq!(world.resource::>().get(h).unwrap().params.rows[0], Vec4::new(2., 3., 4., 5.)); assert!(world.get::>(torso).is_none()); diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl index d9c99bfd..0e0abb92 100644 --- a/crates/skate-game/src/retail_character.wgsl +++ b/crates/skate-game/src/retail_character.wgsl @@ -1,5 +1,5 @@ #import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame} -#import skate_character_lighting::{CharacterParams, shade_character} +#import skate_character_lighting::{CharacterParams, shade_character, character_normal, character_albedo} @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: CharacterParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; @@ -11,24 +11,9 @@ @fragment fn fragment(i: VertexOutput) -> @location(0) vec4 { let albedo = textureSample(diffuse,diffuse_sampler,i.uv); - // GLTF diffuse is sampled as sRGB; recover retail's gamma-two decode. - let gamma = select(albedo.rgb*12.92, 1.055*pow(max(albedo.rgb,vec3(0.0)),vec3(1.0/2.4))-0.055, albedo.rgb>vec3(0.0031308)); - let d = gamma*gamma*p.tint.rgb; - let n = normalize(i.world_normal); - let rpos = i.world_position.xyz-frame::view.world_position; - let vd = -normalize(rpos); - let dp1=dpdx(rpos); let dp2=dpdy(rpos); - let du1=dpdx(i.uv); let du2=dpdy(i.uv); - var tt=cross(dp2,n)*du1.x+cross(n,dp1)*du2.x; - var bb=cross(dp2,n)*du1.y+cross(n,dp1)*du2.y; - tt *= -inverseSqrt(max(dot(tt,tt),1e-12)); - bb *= inverseSqrt(max(dot(bb,bb),1e-12)); - var vn=n; - if p.options.x>0.0 { - // Installed normal PNGs already unpack DXT5nm into RGB. - let nm=textureSample(normal_map,normal_sampler,i.uv).xy*2.0-1.0; - vn=normalize(nm.x*tt+nm.y*bb+n*sqrt(saturate(1.0-dot(nm,nm)))); - } + let d=character_albedo(albedo.rgb)*p.tint.rgb; + let mapped=character_normal(i.world_position,i.world_normal,i.uv,textureSample(normal_map,normal_sampler,i.uv).xy); + let vn=select(normalize(i.world_normal),mapped,p.options.x>0.0); var smask=albedo.a; if p.options.y>0.0 { smask=textureSample(mask_map,mask_sampler,i.uv).r; } return shade_character(p, vn, i.world_position, d, smask, albedo.a); diff --git a/crates/skate-game/src/retail_character_common.wgsl b/crates/skate-game/src/retail_character_common.wgsl index af2f493c..31158dcc 100644 --- a/crates/skate-game/src/retail_character_common.wgsl +++ b/crates/skate-game/src/retail_character_common.wgsl @@ -6,6 +6,26 @@ struct CharacterParams { rows: array,9>, sh: array,9>, } +// Both original and modular stock meshes use the authored derivative basis. +// Modular GLBs intentionally omit tangents, so StandardMaterial's tangent-only +// normal mapping cannot supply this detail after an outfit/model swap. +fn character_normal(position: vec4, normal: vec3, uv: vec2, sample: vec2) -> vec3 { + let n=normalize(normal); + let rpos=position.xyz-frame::view.world_position; + let dp1=dpdx(rpos); let dp2=dpdy(rpos); + let du1=dpdx(uv); let du2=dpdy(uv); + var tt=cross(dp2,n)*du1.x+cross(n,dp1)*du2.x; + var bb=cross(dp2,n)*du1.y+cross(n,dp1)*du2.y; + tt *= -inverseSqrt(max(dot(tt,tt),1e-12)); + bb *= inverseSqrt(max(dot(bb,bb),1e-12)); + let nm=sample*2.0-1.0; + return normalize(nm.x*tt+nm.y*bb+n*sqrt(saturate(1.0-dot(nm,nm)))); +} +fn character_albedo(linear: vec3) -> vec3 { + let gamma=select(linear*12.92,1.055*pow(max(linear,vec3(0.0)),vec3(1.0/2.4))-0.055,linear>vec3(0.0031308)); + return gamma*gamma; +} + fn shade_character(p: CharacterParams, vn: vec3, position: vec4, d: vec3, smask: f32, alpha: f32) -> vec4 { let vd = -normalize(position.xyz-frame::view.world_position); if p.options.w>0.0 { diff --git a/crates/skate-net/Cargo.toml b/crates/skate-net/Cargo.toml index 3ea6c094..03a989f4 100644 --- a/crates/skate-net/Cargo.toml +++ b/crates/skate-net/Cargo.toml @@ -7,7 +7,9 @@ publish.workspace = true [dependencies] serde.workspace = true serde_json.workspace = true +blake3 = "=1.8.7" half = "=2.7.1" +socket2 = "0.6" [dev-dependencies] skate-core.workspace = true diff --git a/crates/skate-net/src/blob.rs b/crates/skate-net/src/blob.rs new file mode 100644 index 00000000..71a2fda1 --- /dev/null +++ b/crates/skate-net/src/blob.rs @@ -0,0 +1,455 @@ +//! Reliable, bounded appearance streams. Movement is serviced first by the lobby. +//! Selective acknowledgements slide continuously; no page-sized round trips. +use crate::{lobby::Outgoing, packed}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +pub const META: u8 = 12; +pub const DATA: u8 = 13; +pub const ACK: u8 = 14; +pub const MAX_BLOB: usize = 64 * 1024 * 1024; +const MEMORY: usize = (crate::lobby::MAX_PLAYERS + 2) * MAX_BLOB; +const CHUNK: usize = 1024; +const WINDOW: usize = 1024; +const MASK: usize = WINDOW / 8; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Identity { + pub hash: [u8; 32], + pub size: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + fn manifest(bytes: &[u8]) -> Vec { + let mut b = blake3::hash(bytes).as_bytes().to_vec(); + b.extend((bytes.len() as u32).to_le_bytes()); + b + } + #[test] + fn blob_bounds_hash_validation_and_obsolete_chunks() { + let mut b = Blobs::default(); + let mut oversized = vec![0; 32]; + oversized.extend(((MAX_BLOB + 1) as u32).to_le_bytes()); + b.receive(1, 10, META, 1, &oversized); + assert!(b.cache.is_empty()); + b.receive(1, 10, META, 1, &manifest(&[1, 2])); + let mut wrong = 0u32.to_le_bytes().to_vec(); + wrong.extend([3, 4]); + b.receive(1, 10, DATA, 1, &wrong); + assert!( + b.ready(10).is_none(), + "corrupt content must never be loaded" + ); + b.receive(1, 10, META, 2, &manifest(&[5, 6])); + b.receive(1, 10, DATA, 1, &wrong); + b.receive(1, 10, META, 1, &manifest(&[1, 2])); + assert_eq!(b.actors[&10].0, 2); + let mut valid = 0u32.to_le_bytes().to_vec(); + valid.extend([5, 6]); + b.receive(1, 10, DATA, 2, &valid); + assert_eq!(b.ready(10).unwrap().1, &[5, 6]); + // A later peer can consume verified cached bytes without data retransmission. + b.receive(2, 11, META, 1, &manifest(&[5, 6])); + assert_eq!(b.ready(11).unwrap().1, &[5, 6]); + } + #[test] + fn lobby_rejects_forged_blob_origins_and_unregistered_senders() { + use crate::lobby::{Info, Session}; + let info = |id| Info { + id, + map: 1, + rig: 1, + physics: 1, + appearance: 1, + }; + let mut host = Session::new(88, info(10), None); + let mut client = Session::new(88, info(11), Some(1)); + for p in client.service(1) { + host.receive(2, &p.data, 1); + } + let mut forged = packed::header(88, 12, META, 1); + forged.extend(manifest(&[9])); + host.receive(2, &forged, 2); + assert!(host.blobs.actors.is_empty()); + let mut valid = packed::header(88, 11, META, 1); + valid.extend(manifest(&[9])); + host.receive(3, &valid, 3); + assert!(host.blobs.actors.is_empty()); + host.receive(2, &valid, 4); + assert!(host.blobs.actors.contains_key(&11)); + } +} +struct Blob { + id: Identity, + bytes: Arc>, + received: Vec, + prefix: usize, + complete: bool, + failed: bool, +} +impl Blob { + fn count(&self) -> usize { + self.id.size.div_ceil(CHUNK) + } +} +struct Sending { + seq: u32, + meta_at: Option, + accepted: bool, + prefix: usize, + mask: [u8; MASK], + sent: BTreeMap, + window: usize, + last_loss: u64, +} +impl Sending { + fn new(seq: u32) -> Self { + Self { + seq, + meta_at: None, + accepted: false, + prefix: 0, + mask: [0; MASK], + sent: BTreeMap::new(), + window: 64, + last_loss: 0, + } + } + fn has(&self, i: usize) -> bool { + i < self.prefix + || (i - self.prefix < WINDOW + && self.mask[(i - self.prefix) / 8] & (1 << ((i - self.prefix) % 8)) != 0) + } +} +#[derive(Default)] +pub struct Blobs { + actors: BTreeMap, + cache: BTreeMap<[u8; 32], Blob>, + sending: BTreeMap<(u64, u64), Sending>, + acknowledgements: BTreeSet<(u64, u64)>, + credits: BTreeMap, + last: u64, + round: usize, + pub congested: bool, +} +impl Blobs { + fn reserve(&mut self, size: usize) -> bool { + let mut used: usize = self.cache.values().map(|b| b.id.size).sum(); + let unused: Vec<_> = self + .cache + .keys() + .filter(|hash| !self.actors.values().any(|(_, h)| h == *hash)) + .copied() + .collect(); + for hash in unused { + if used + size <= MEMORY { + break; + } + used -= self.cache.remove(&hash).unwrap().id.size; + } + used + size <= MEMORY + } + pub fn publish(&mut self, actor: u64, bytes: Vec) -> bool { + if bytes.is_empty() || bytes.len() > MAX_BLOB { + return false; + } + let hash = *blake3::hash(&bytes).as_bytes(); + if self.actors.get(&actor).is_some_and(|(_, h)| *h == hash) { + return true; + } + if self.cache.get(&hash).is_none_or(|b| !b.complete) { + if !self.cache.contains_key(&hash) && !self.reserve(bytes.len()) { + return false; + } + let count = bytes.len().div_ceil(CHUNK); + self.cache.insert( + hash, + Blob { + id: Identity { + hash, + size: bytes.len(), + }, + bytes: Arc::new(bytes), + received: vec![], + prefix: count, + complete: true, + failed: false, + }, + ); + } + let seq = self + .actors + .get(&actor) + .map_or(1, |(s, _)| s.saturating_add(1)); + self.actors.insert(actor, (seq, hash)); + true + } + pub fn ready(&self, actor: u64) -> Option<(&Identity, &[u8])> { + let (_, hash) = self.actors.get(&actor)?; + let b = self.cache.get(hash)?; + b.complete.then_some((&b.id, b.bytes.as_slice())) + } + pub fn progress(&self, local: u64) -> (usize, usize) { + self.actors + .iter() + .filter(|(id, _)| **id != local) + .filter_map(|(_, (_, h))| self.cache.get(h)) + .filter(|b| !b.complete) + .fold((0, 0), |(done, total), b| { + ( + done + b.received.iter().filter(|v| **v).count(), + total + b.count(), + ) + }) + } + // The lobby authenticates origin/peer and bounds membership before calling this. + pub(crate) fn receive(&mut self, peer: u64, actor: u64, kind: u8, seq: u32, data: &[u8]) { + if kind == META { + if data.len() != 36 || seq == 0 { + return; + } + let hash: [u8; 32] = data[..32].try_into().unwrap(); + let size = u32::from_le_bytes(data[32..].try_into().unwrap()) as usize; + if size == 0 || size > MAX_BLOB { + return; + } + if self + .actors + .get(&actor) + .is_some_and(|(old, h)| seq < *old || (seq == *old && hash != *h)) + { + return; + } + if let Some(b) = self.cache.get(&hash) { + if b.id.size != size { + return; + } + } else { + if !self.reserve(size) { + return; + } + self.cache.insert( + hash, + Blob { + id: Identity { hash, size }, + bytes: Arc::new(vec![0; size]), + received: vec![false; size.div_ceil(CHUNK)], + prefix: 0, + complete: false, + failed: false, + }, + ); + } + self.actors.insert(actor, (seq, hash)); + self.acknowledgements.insert((peer, actor)); + } else if kind == DATA { + let Some((current, hash)) = self.actors.get(&actor) else { + return; + }; + if seq != *current || data.len() < 5 { + return; + } + let b = self.cache.get_mut(hash).unwrap(); + let index = u32::from_le_bytes(data[..4].try_into().unwrap()) as usize; + if index >= b.count() || data.len() - 4 != CHUNK.min(b.id.size - index * CHUNK) { + return; + } + self.acknowledgements.insert((peer, actor)); + if b.complete || b.failed || b.received[index] { + return; + } + Arc::get_mut(&mut b.bytes).unwrap()[index * CHUNK..index * CHUNK + data.len() - 4] + .copy_from_slice(&data[4..]); + b.received[index] = true; + while b.prefix < b.count() && b.received[b.prefix] { + b.prefix += 1; + } + if b.prefix == b.count() { + b.complete = blake3::hash(&b.bytes).as_bytes() == &b.id.hash; + b.failed = !b.complete; + // Bad bytes never reach the asset loader or trigger an endless retry. + } + } else if kind == ACK { + if data.len() != 12 + MASK { + return; + } + let origin = u64::from_le_bytes(data[..8].try_into().unwrap()); + let prefix = u32::from_le_bytes(data[8..12].try_into().unwrap()) as usize; + let Some((current, hash)) = self.actors.get(&origin) else { + return; + }; + let count = self.cache[hash].count(); + let Some(tx) = self.sending.get_mut(&(peer, origin)) else { + return; + }; + if seq != *current || seq != tx.seq || prefix > count || prefix < tx.prefix { + return; + } + if prefix == tx.prefix { + for (a, b) in tx.mask.iter_mut().zip(&data[12..]) { + *a |= *b; + } + } else { + tx.mask.copy_from_slice(&data[12..]); + tx.prefix = prefix; + } + tx.accepted = true; + let remove: Vec<_> = tx.sent.keys().filter(|i| tx.has(**i)).copied().collect(); + tx.window = (tx.window + remove.len()).min(WINDOW); + for i in remove { + tx.sent.remove(&i); + } + } + } + pub(crate) fn service( + &mut self, + session: u64, + local: u64, + host: bool, + members: &BTreeSet, + peers: &[(u64, u64, u64)], + now: u64, + ) -> Vec { + self.actors.retain(|id, _| members.contains(id)); + self.sending.retain(|(peer, id), _| { + members.contains(id) && peers.iter().any(|(p, _, _)| p == peer) + }); + self.credits + .retain(|peer, _| peers.iter().any(|(p, _, _)| p == peer)); + let mut out = vec![]; + for (peer, origin) in std::mem::take(&mut self.acknowledgements) { + if !peers.iter().any(|(p, _, _)| *p == peer) { + continue; + } + let Some((seq, hash)) = self.actors.get(&origin) else { + continue; + }; + let b = &self.cache[hash]; + let mut data = packed::header(session, local, ACK, *seq); + data.extend(origin.to_le_bytes()); + data.extend((b.prefix as u32).to_le_bytes()); + let mut mask = [0u8; MASK]; + for i in b.prefix..(b.prefix + WINDOW).min(b.count()) { + if b.complete || b.received[i] { + mask[(i - b.prefix) / 8] |= 1 << ((i - b.prefix) % 8); + } + } + data.extend(mask); + out.push(Outgoing { peer, data }); + } + let dt = now.saturating_sub(self.last).min(1000) as f64 / 1000.; + self.last = now; + // Same rates for direct and Steam. Bursts cover slow render frames; ACKs + // bound outstanding data and relay congestion reduces bulk throughput. + let rate = if self.congested { 128_000. } else { 4_000_000. }; + let mut global = 2_000_000usize; + let mut peers = peers.to_vec(); + if !peers.is_empty() { + let n = peers.len(); + peers.rotate_left(self.round % n); + } + self.round = self.round.wrapping_add(1); + 'peers: for (peer, recipient, rtt) in peers { + if recipient == 0 { + continue; + } + let credit = self.credits.entry(peer).or_default(); + *credit = (*credit + dt * rate).min(512_000.); + let mut origins: Vec<_> = self + .actors + .iter() + .filter(|(id, _)| **id != recipient && (host || **id == local)) + .map(|(&id, _)| id) + .collect(); + if !origins.is_empty() { + let n = origins.len(); + origins.rotate_left(self.round % n); + } + for origin in &origins { + let (seq, hash) = self.actors[origin]; + let b = &self.cache[&hash]; + let tx = self + .sending + .entry((peer, *origin)) + .or_insert_with(|| Sending::new(seq)); + if tx.seq != seq { + *tx = Sending::new(seq); + } + if !tx.accepted && tx.meta_at.is_none_or(|at| now.saturating_sub(at) >= 500) { + let mut data = packed::header(session, *origin, META, seq); + data.extend(hash); + data.extend((b.id.size as u32).to_le_bytes()); + out.push(Outgoing { peer, data }); + tx.meta_at = Some(now); + } + } + // Round robin chunks across actors, with a sliding selective-ACK window. + let mut candidates = vec![]; + for origin in origins { + let (_, hash) = self.actors[&origin]; + let b = &self.cache[&hash]; + let tx = self.sending.get_mut(&(peer, origin)).unwrap(); + if !tx.accepted || b.failed { + continue; + } + let retry = (rtt * 3 + 200).clamp(400, 2000); + if now.saturating_sub(tx.last_loss) >= retry + && tx.sent.values().any(|at| now.saturating_sub(*at) >= retry) + { + tx.window = (tx.window / 2).max(32); + tx.last_loss = now; + } + let chunks: Vec<_> = (tx.prefix..(tx.prefix + tx.window).min(b.count())) + .filter(|&i| { + !tx.has(i) + && (b.complete || b.received[i]) + && tx + .sent + .get(&i) + .is_none_or(|at| now.saturating_sub(*at) >= retry) + }) + .collect(); + candidates.push((origin, chunks.into_iter())); + } + loop { + let mut sent = false; + for (origin, indices) in &mut candidates { + let Some(i) = indices.next() else { + continue; + }; + let (seq, hash) = self.actors[origin]; + let b = &self.cache[&hash]; + let start = i * CHUNK; + let end = (start + CHUNK).min(b.id.size); + let size = packed::HEADER + 4 + end - start; + if global < size { + return out; + } + if *credit < size as f64 { + continue 'peers; + } + let mut data = packed::header(session, *origin, DATA, seq); + data.extend((i as u32).to_le_bytes()); + data.extend(&b.bytes[start..end]); + self.sending + .get_mut(&(peer, *origin)) + .unwrap() + .sent + .insert(i, now); + *credit -= size as f64; + global -= size; + out.push(Outgoing { peer, data }); + sent = true; + } + if !sent { + break; + } + } + } + out + } +} diff --git a/crates/skate-net/src/lib.rs b/crates/skate-net/src/lib.rs index 84051ffd..761ce2f4 100644 --- a/crates/skate-net/src/lib.rs +++ b/crates/skate-net/src/lib.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use std::time::{Duration, Instant}; mod codec; pub mod lobby; +pub mod blob; +pub mod socket; pub mod directory; pub mod packed; pub mod interpolation; diff --git a/crates/skate-net/src/lobby.rs b/crates/skate-net/src/lobby.rs index 09ea8b13..575c2e94 100644 --- a/crates/skate-net/src/lobby.rs +++ b/crates/skate-net/src/lobby.rs @@ -158,6 +158,7 @@ pub struct Session { link_budget: f64, round: usize, loopback: bool, + pub blobs: crate::blob::Blobs, } impl Session { pub fn new(session: u64, info: Info, host: Option) -> Self { @@ -190,6 +191,7 @@ impl Session { link_budget: DEFAULT_LINK_BUDGET, round: 0, loopback: false, + blobs: Default::default(), } } /// An external membership authority chooses the new endpoint. Preserve our @@ -229,6 +231,7 @@ impl Session { self.is_host() || self.received_roster != 0 } pub fn set_congested(&mut self, congested: bool) { + self.blobs.congested = congested; self.link_budget = if congested { DEFAULT_LINK_BUDGET * 0.5 } else { @@ -373,6 +376,19 @@ impl Session { .into(); return; } + if matches!(kind, crate::blob::META | crate::blob::DATA | crate::blob::ACK) { + let valid = if kind == crate::blob::ACK { + self.links[&peer].actor == actor + } else { + actor != self.local && self.actors.contains_key(&actor) + && (!self.is_host() || self.links[&peer].actor == actor) + }; + if valid { + self.blobs.receive(peer, actor, kind, seq, &data[HEADER..]); + self.links.get_mut(&peer).unwrap().seen = now; + } + return; + } if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE | APPLICATION) { return; } @@ -691,6 +707,9 @@ impl Session { output.push(Outgoing {peer, data}); } } + let members = self.actors.keys().copied().collect(); + let peers: Vec<_> = self.links.iter().map(|(&peer, link)| (peer, link.actor, link.rtt)).collect(); + output.extend(self.blobs.service(self.session, self.local, self.host.is_none(), &members, &peers, now)); self.stats.rtt_ms = self.links.values().map(|l| l.rtt).max().unwrap_or(0); output } diff --git a/crates/skate-net/src/socket.rs b/crates/skate-net/src/socket.rs new file mode 100644 index 00000000..729a5c92 --- /dev/null +++ b/crates/skate-net/src/socket.rs @@ -0,0 +1,8 @@ +//! Datagram queues must hold a render frame's bulk traffic, including relay IPC. +use std::{io, net::UdpSocket}; +pub fn configure(socket: &UdpSocket) -> io::Result<()> { + let socket = socket2::SockRef::from(socket); + socket.set_recv_buffer_size(4 * 1024 * 1024)?; + socket.set_send_buffer_size(4 * 1024 * 1024)?; + socket.set_nonblocking(true) +} diff --git a/crates/skate-steam-relay/Cargo.toml b/crates/skate-steam-relay/Cargo.toml index f5a827a9..7ddac7b8 100644 --- a/crates/skate-steam-relay/Cargo.toml +++ b/crates/skate-steam-relay/Cargo.toml @@ -9,5 +9,5 @@ name = "skate-steam-relay" path = "src/main.rs" [dependencies] -steamworks = "=0.13.1" +steamworks = { version = "=0.13.1", features = ["raw-bindings"] } skate-net = { path = "../skate-net" } diff --git a/crates/skate-steam-relay/src/main.rs b/crates/skate-steam-relay/src/main.rs index fbdf4451..b9743687 100644 --- a/crates/skate-steam-relay/src/main.rs +++ b/crates/skate-steam-relay/src/main.rs @@ -32,7 +32,7 @@ fn run() -> Result<(), String> { let cookie = &args[3]; let socket = UdpSocket::bind("127.0.0.1:0").map_err(|e| e.to_string())?; socket.connect(parent).map_err(|e| e.to_string())?; - socket.set_nonblocking(true).map_err(|e| e.to_string())?; + skate_net::socket::configure(&socket).map_err(|e| e.to_string())?; let status = |s: &str| { let _ = socket.send(format!("SK8RELAY {cookie} {s}").as_bytes()); }; @@ -52,6 +52,19 @@ fn run() -> Result<(), String> { return Err("Same Steam identity".into()); } client.networking_utils().init_relay_network_access(); + // SDK defaults cap model uploads at 256 KB/s. Raise only the ceiling; + // retain Steam's congestion control and the lobby's bulk pacing/backoff. + // SAFETY: Steam is initialized and owns this interface for `client`'s lifetime. + unsafe { + let utils = steamworks::sys::SteamAPI_SteamNetworkingUtils_SteamAPI_v004(); + if utils.is_null() || !steamworks::sys::SteamAPI_ISteamNetworkingUtils_SetGlobalConfigValueInt32( + utils, + steamworks::sys::ESteamNetworkingConfigValue::k_ESteamNetworkingConfig_SendRateMax, + 4_500_000, + ) { + return Err("Could not configure Steam model transfer bandwidth".into()); + } + } let messages = client.networking_messages(); let mut attempts = 0; let mut window = Instant::now(); @@ -144,7 +157,7 @@ fn run() -> Result<(), String> { )); heartbeat = Instant::now(); } - for _ in 0..256 { + for _ in 0..4096 { match socket.recv(&mut buffer) { Ok(n) => { if buffer[..n] == *ping.as_bytes() { @@ -187,7 +200,7 @@ fn run() -> Result<(), String> { } parent_seen = Instant::now(); let state = matches!(kind, skate_net::packed::BODY | skate_net::packed::POSE); - if state && peers.get(&target).is_some_and(|(_, queue)| *queue > 50) { + if (state || kind == skate_net::blob::DATA) && peers.get(&target).is_some_and(|(_, queue)| *queue > 50) { dropped += 1; continue; } @@ -213,7 +226,7 @@ fn run() -> Result<(), String> { Err(e) => return Err(e.to_string()), } } - for message in messages.receive_messages_on_channel(CHANNEL, 256) { + for message in messages.receive_messages_on_channel(CHANNEL, 4096) { let id = message .identity_peer() .steam_id() diff --git a/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index 0cd22c69..5c9ac079 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -181,3 +181,43 @@ launcher records stdout/stderr, backtraces and the exit code in See [visual-parity-status.md](visual-parity-status.md) for the subsequent SH transition smoothing, second-UV blended hair coverage, GPU exposure meter and remaining shadow/CAC fidelity gaps. Earlier implementation sections are historical. + +## Stock surface restoration, September 9 + +Returning from an imported/native model reapplies the modular stock outfit. Its +geometry-only GLBs have normal maps in the material library but no tangent +attributes, so the StandardMaterial input path silently omitted their detail. +The customiser shader also used alpha after `alpha_discard` as the clothing +specular mask; opaque coverage had already replaced that authored mask with 1. + +Both original and modular retail shaders now share the derivative normal basis +and gamma-two albedo decode. Modular materials retain the source diffuse alpha +for highlights, independently of opacity and tattoos, and use the dedicated skin +specular mask where authored. Profile updates retain texture handles and apply +colour tint separately. This shared material path covers local and remote stock +outfits without changing imported PBR materials or globally lowering gloss. + +Fourteen focused tests pass with the prepared library, including offline shader +compilation, stock restoration and per-profile material preservation. No game was +launched; visual verification of stock -> imported/pro -> stock remains manual. + +## Pro hair family classification, September 9 + +Benny Fairfax's prepared `Retail_Hair` uses `character.default_hair`; other pro +hair also uses `character.default_hair_ropa`. The renderer previously recognised +only the `character.hair` prefix, so those pro materials entered clothing shading +and received bright, untinted specular highlights. A shared material-family check +now recognises all four authored hair names for local/native and modular/remote +materials. The existing hair response and original textures are preserved. + +Fifteen focused tests pass, including a native hair binding regression and checks +that skin/clothing are not classified as hair. No asset conversion is required; +Benny's appearance still needs the user-run visual check. + +Stock-hair follow-up: all 31 modular stock hair materials use `character.hair` +or `character.hair_ropa`; the original stock material also uses `character.hair`. +The prepared-library regression passes a colour change/restore for every stock +hair material, preserving hair shading, authored tint, normal/diffuse/opacity +textures, specular masks and roughness. This rules out the same family-selection +and tested profile-cache failure, not every possible visual lighting issue. +No runtime change or new executable was needed for this audit. diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md index b9a5a6a0..5c1857e0 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -1,5 +1,53 @@ # Multiplayer development + +## Online characters + +Current builds replicate the equipped outfit, including clothing/material choices, +face/body morphs, colours and tattoos, and all prepared pro/special/DLC character +identities. Each receiving installation resolves retail identities from its own +prepared character library. The same setup update that supplies the local +customiser supplies these online assets. + +Imported characters transfer their converted, self-contained GLB automatically; +other players do not need to import the model. There is no installed-library scan +or local-file bypass. Direct and Steam use the same authenticated bulk stream. +The host forwards available chunks while downloading. A sliding selective-ACK +window replaces the old 32-chunk request/wait cycle. Each peer receives the current +model once; completed content is cached for later swaps, and a late join receives +the current selection. New selections cancel obsolete transfers. +Movement/collision packets are serviced first. Bulk traffic has its own bounded +4 MB/s per-link ceiling, grows its window as chunks are acknowledged, backs off +on loss/relay congestion, and retries missing +chunks. Datagram queues hold slow render frames' traffic on both direct sockets +and Steam IPC. The Steam send-rate ceiling is raised from its small-message +default. The HUD shows character transfer progress. +Each remote keeps its visible character until the new scenes, textures and every +clothing rig are ready. A replacement is posed before visibility is published; +paused local swaps use the current skeleton and remote swaps use the latest +received pose (or the idle pose while waiting for a sample). +Imported textures retain their own materials; retail +characters use their authored clothing lighting. Remote colours/tattoos never +modify the local player or another remote's materials. + +All players need this build to see appearances; older builds continue to display +their stock fallback. Missing retail content or rejected imports also retain a +fallback. Online imports are limited to 64 MiB, standard embedded PNG/JPEG glTF +content, 8192 pixels per texture dimension and 64 million total texture pixels. +The network cache is bounded to twelve maximum-sized blobs (768 MiB), with inactive +content evicted when needed; active actor content is retained for forwarding. Temporary imports +are separate from the personal character library and cleaned up on leaving/exiting. + +Manual regression: use two PCs/accounts or local clients with separate settings. +Choose different clothes, skin colours, tattoos and body/face morphs. Verify both +views, then switch to a pro, a special/DLC skater, an imported model the other +player does not own, and back to the customised stock skater. Join a third client +after the selections; check it receives the latest appearances. Test a change +while downloading, leave/rejoin, and a host departure with three Steam clients. +Skate, bail, retrieve the board and enter vehicles; every remote clothing rig and +board should follow the existing network pose. Nongame tests simulate ten players +with loss, duplication, reordering, a late join and changes during transfer. + Build the game and Steam relay with `./scripts/build-multiplayer-test.ps1`. The output is staged in `bin/multiplayer`. @@ -86,6 +134,25 @@ cache. Supply your prepared asset view through `-AssetRoot`. If using a separate session-marker overlay, set `SKATE3_SESSION_MARKER_OVERLAY` before launching. The merged game and Steam relay were compiled without launching either. Actual -Steam connectivity still needs two PCs with separate Steam accounts. Remote clothing -replication remains the implementation's appearance-ID/fallback mechanism; merging -the character customiser does not add transmission of its full clothing selections. +Steam connectivity still needs two PCs with separate Steam accounts. Current +appearance replication is described in the Online characters section above. + +### Network stream regression (September 9) + +The previous page exchange took approximately two minutes for the reported 32 MB +model. The installed-file shortcut has been removed. Production UDP sockets now +transfer 32,067,793 bytes through client -> host -> client in approximately 8.4 s +at a deliberately limited 15 FPS, with movement continuing and no model chunks +sent after acknowledgement. This is measured transfer time, not a claim of +instantaneous delivery or a Steam/WAN measurement. + +Nine headless character tests pass, including the real owned import transferred +through UDP before Bevy loads it from the receiver's private source, binds the +rig and applies successive poses. Other checks cover male/female clothing rigs, +material isolation, swapped rigs, invalid GLBs, and ten peers with delay/loss, +reordering, late joins and changed selections. The transport adds explicit +origin-authentication, stale-generation and corrupt-content regression tests. +No game or Steam client was launched. Manual tests should check both screens, +including skating/bailing and switching back to stock. Logs distinguish download +completion (`ONLINE_CHARACTER_READY`) from a posed scene becoming visible +(`ONLINE_CHARACTER_VISIBLE`).