From f8e10c8ec922629fe0417a914bc0e819f9df4ce0 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 16:22:44 +0100 Subject: [PATCH 1/8] Synchronize online outfits and native characters with bounded custom model transfer --- Cargo.lock | 2 + crates/skate-game/Cargo.toml | 2 + crates/skate-game/src/animation.rs | 4 + crates/skate-game/src/custom_models.rs | 10 + crates/skate-game/src/customiser_parts.rs | 141 +++--- crates/skate-game/src/multiplayer.rs | 18 +- .../skate-game/src/multiplayer/appearance.rs | 338 ++++++++++++++ .../src/multiplayer/appearance_transfer.rs | 309 +++++++++++++ crates/skate-game/src/multiplayer/render.rs | 429 +++++++++++++++--- crates/skate-game/src/retail_character.rs | 4 +- docs/multiplayer-test.md | 35 ++ 11 files changed, 1154 insertions(+), 138 deletions(-) create mode 100644 crates/skate-game/src/multiplayer/appearance.rs create mode 100644 crates/skate-game/src/multiplayer/appearance_transfer.rs diff --git a/Cargo.lock b/Cargo.lock index 07dccbec..cff621a6 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", 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..01128cba 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -16,6 +16,10 @@ struct BoneBinding { parent_bone: Option, } impl AnimationStatus { + 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, diff --git a/crates/skate-game/src/custom_models.rs b/crates/skate-game/src/custom_models.rs index b90abdbe..6c112f9b 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", diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index fb3ac888..29e950f8 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,42 @@ 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 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; @@ -659,82 +701,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 +803,24 @@ 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::default(),extension:SkinStamp::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.)); + 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()); + } +} diff --git a/crates/skate-game/src/multiplayer.rs b/crates/skate-game/src/multiplayer.rs index 5f849385..3e23cbee 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)) } @@ -343,6 +346,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); } } @@ -481,9 +487,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(), @@ -560,7 +564,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 { @@ -638,15 +642,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..69785c81 --- /dev/null +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -0,0 +1,338 @@ +//! 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, + 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 = format!("{:?}:{}", models.active, 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) => { + 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.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) => { + 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..8b1f5317 --- /dev/null +++ b/crates/skate-game/src/multiplayer/appearance_transfer.rs @@ -0,0 +1,309 @@ +//! Bounded appearance transfer over the lobby's authenticated, budgeted record stream. +//! Fixed keys keep late joins/retries reliable without growing lobby state per file. +use skate_net::lobby::Session; +use std::collections::BTreeMap; +const META: &str = "@look/meta"; +const CHUNK: usize = 980; +const WINDOW: usize = 32; +pub const MAX_BLOB: usize = 64 * 1024 * 1024; +const SESSION_BUDGET: usize = 256 * 1024 * 1024; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Identity { + pub hash: [u8; 32], + pub size: usize, +} +impl Identity { + fn read(b: &[u8]) -> Option { + if b.len() != 36 { + return None; + } + let size = u32::from_le_bytes(b[32..].try_into().ok()?) as usize; + (size > 0 && size <= MAX_BLOB).then(|| Self { + hash: b[..32].try_into().unwrap(), + size, + }) + } + fn bytes(&self) -> Vec { + let mut b = self.hash.to_vec(); + b.extend((self.size as u32).to_le_bytes()); + b + } +} +struct Incoming { + id: Identity, + bytes: Vec, + received: Vec, +} +#[derive(Default)] +pub struct Exchange { + local: Option<(Identity, Vec)>, + incoming: BTreeMap, + pub ready: BTreeMap)>, + spent: usize, + round: usize, + next: u64, +} +impl Exchange { + pub fn progress(&self) -> String { + let total: usize = self.incoming.values().map(|p| p.received.len()).sum(); + let done: usize = self + .incoming + .values() + .map(|p| p.received.iter().filter(|v| **v).count()) + .sum(); + if total == 0 { + format!("Characters: {} synced", self.ready.len()) + } else { + format!( + "Characters: {} synced | downloading {} ({}%)", + self.ready.len(), + self.incoming.len(), + done * 100 / total + ) + } + } + pub fn publish(&mut self, bytes: Vec) -> bool { + if bytes.is_empty() || bytes.len() > MAX_BLOB { + return false; + } + let id = Identity { + hash: *blake3::hash(&bytes).as_bytes(), + size: bytes.len(), + }; + self.local = Some((id, bytes)); + true + } + pub fn tick(&mut self, session: &mut Session, now: u64) { + if now < self.next { + return; + } + self.next = now + 150; + self.incoming + .retain(|id, _| session.actors.contains_key(id)); + self.ready.retain(|id, _| session.actors.contains_key(id)); + let Some((local, bytes)) = &self.local else { + return; + }; + session.publish_application(META, local.bytes(), now); + let mut records = vec![]; + let mut requests = vec![]; + for (slot, (&actor, a)) in session.actors.iter().enumerate() { + if actor == session.local { + continue; + } + for r in a + .application + .iter() + .filter(|(key, _)| key.starts_with("@look/r/")) + .map(|(_, r)| r) + { + if r.value.len() == 44 + && u64::from_le_bytes(r.value[..8].try_into().unwrap()) == session.local + && r.value[8..40] == local.hash + { + let page = u32::from_le_bytes(r.value[40..].try_into().unwrap()) as usize; + if page < bytes.len().div_ceil(CHUNK * WINDOW) { + requests.push(page); + } + } + } + let Some(id) = a + .application + .get(META) + .and_then(|r| Identity::read(&r.value)) + else { + continue; + }; + if self.ready.get(&actor).is_some_and(|(old, _)| old == &id) { + continue; + } + if self.incoming.get(&actor).is_none_or(|p| p.id != id) { + if self.spent.saturating_add(id.size) > SESSION_BUDGET { + continue; + } + self.spent += id.size; + self.incoming.insert( + actor, + Incoming { + received: vec![false; id.size.div_ceil(CHUNK)], + bytes: vec![0; id.size], + id, + }, + ); + } + let p = self.incoming.get_mut(&actor).unwrap(); + for i in 0..WINDOW { + let Some(r) = a.application.get(&format!("@look/c/{i}")) else { + continue; + }; + let b = &r.value; + if b.len() < 37 || b[..32] != p.id.hash { + continue; + } + let offset = u32::from_le_bytes(b[32..36].try_into().unwrap()) as usize; + if offset % CHUNK != 0 + || offset >= p.bytes.len() + || b.len() - 36 != CHUNK.min(p.bytes.len() - offset) + { + continue; + } + p.bytes[offset..offset + b.len() - 36].copy_from_slice(&b[36..]); + p.received[offset / CHUNK] = true; + } + if let Some(missing) = p.received.iter().position(|done| !*done) { + let mut request = actor.to_le_bytes().to_vec(); + request.extend(p.id.hash); + request.extend(((missing / WINDOW) as u32).to_le_bytes()); + records.push((format!("@look/r/{slot}"), request)); + } else { + let p = self.incoming.remove(&actor).unwrap(); + if blake3::hash(&p.bytes).as_bytes() == &p.id.hash { + self.ready.insert(actor, (p.id, p.bytes)); + } + // Clear the request after completion, including corrupted transfers. + records.push((format!("@look/r/{slot}"), vec![])); + } + } + if !requests.is_empty() { + requests.sort_unstable(); + requests.dedup(); + let page = requests[self.round % requests.len()]; + self.round = self.round.wrapping_add(1); + for slot in 0..WINDOW { + let offset = (page * WINDOW + slot) * CHUNK; + if offset >= bytes.len() { + break; + } + let mut b = local.hash.to_vec(); + b.extend((offset as u32).to_le_bytes()); + b.extend(&bytes[offset..(offset + CHUNK).min(bytes.len())]); + records.push((format!("@look/c/{slot}"), b)); + } + } + for (key, value) in records { + session.publish_application(&key, value, now); + } + } +} +#[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; + 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 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() { + assert!(Identity::read(&[]).is_none()); + let id = Identity { + hash: [1; 32], + size: MAX_BLOB + 1, + }; + assert!(Identity::read(&id.bytes()).is_none()); + let mut e = Exchange::default(); + assert!(!e.publish(vec![])); + } +} diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index 701b9c1d..a8be725d 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,236 @@ 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, 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 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]), + )); + } + } + if let Some(old) = skin.visible.replace(p.root) { + commands.entity(old).despawn(); + } + commands.entity(p.root).insert(Visibility::Inherited); + skin.bindings = bindings; + skin.poses = Buffer::default(); + skin.pending = None; } } @@ -276,11 +467,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 +503,120 @@ 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(); + 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(); + 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/retail_character.rs b/crates/skate-game/src/retail_character.rs index 2d8476b8..54a94a80 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -235,9 +235,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 +249,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) }; diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md index e2ee2912..6e97067b 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -1,5 +1,40 @@ # 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. Updates and late joins use bounded, +reliable chunks through the existing host relay. Movement/collision packets keep +priority and existing bandwidth budgets. The HUD shows character transfer progress. +Each remote keeps its visible character until the new scenes, textures and every +clothing rig are ready. 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. +A connection accepts at most 256 MiB of new appearance payloads. 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`. From 94e5f613f3633e2474cd6c2379f545f895d30c98 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 16:38:49 +0100 Subject: [PATCH 2/8] Cache delivered online models and stop completed transfers with fair chunk scheduling --- .../skate-game/src/multiplayer/appearance.rs | 30 +++- .../src/multiplayer/appearance_transfer.rs | 133 +++++++++++++++++- crates/skate-game/src/multiplayer/render.rs | 48 +++++++ crates/skate-net/src/lobby.rs | 12 +- docs/multiplayer-test.md | 8 +- 5 files changed, 220 insertions(+), 11 deletions(-) diff --git a/crates/skate-game/src/multiplayer/appearance.rs b/crates/skate-game/src/multiplayer/appearance.rs index 69785c81..9d60cb4c 100644 --- a/crates/skate-game/src/multiplayer/appearance.rs +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -27,6 +27,7 @@ pub(super) struct Appearances { local: String, pub looks: BTreeMap, seen: BTreeMap, + cached: BTreeMap<[u8; 32], Look>, pub status: String, pub progress: String, } @@ -61,7 +62,11 @@ pub(super) fn sync( }; } let selection = models.online_selection(); - let signature = format!("{:?}:{}", models.active, parts.applied); + 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 { @@ -90,6 +95,10 @@ pub(super) fn sync( })(); match payload { Ok(bytes) => { + info!( + "ONLINE_CHARACTER_PUBLISH bytes={} selection={signature}", + bytes.len() + ); state.exchange.publish(bytes); state.status.clear(); } @@ -119,6 +128,13 @@ pub(super) fn sync( 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()); @@ -147,6 +163,18 @@ pub(super) fn sync( })(); 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}"), diff --git a/crates/skate-game/src/multiplayer/appearance_transfer.rs b/crates/skate-game/src/multiplayer/appearance_transfer.rs index 8b1f5317..e64f0b00 100644 --- a/crates/skate-game/src/multiplayer/appearance_transfer.rs +++ b/crates/skate-game/src/multiplayer/appearance_transfer.rs @@ -40,10 +40,15 @@ pub struct Exchange { incoming: BTreeMap, pub ready: BTreeMap)>, spent: usize, + known: BTreeMap<[u8; 32], Identity>, round: usize, next: u64, + serving: Option<([u8; 32], usize, u64)>, } impl Exchange { + pub fn remember(&mut self, id: Identity) { + self.known.insert(id.hash, id); + } pub fn progress(&self) -> String { let total: usize = self.incoming.values().map(|p| p.received.len()).sum(); let done: usize = self @@ -77,7 +82,7 @@ impl Exchange { if now < self.next { return; } - self.next = now + 150; + self.next = now + 25; self.incoming .retain(|id, _| session.actors.contains_key(id)); self.ready.retain(|id, _| session.actors.contains_key(id)); @@ -115,6 +120,12 @@ impl Exchange { continue; }; if self.ready.get(&actor).is_some_and(|(old, _)| old == &id) { + self.incoming.remove(&actor); + continue; + } + if self.known.get(&id.hash) == Some(&id) { + self.incoming.remove(&actor); + self.ready.insert(actor, (id, vec![])); continue; } if self.incoming.get(&actor).is_none_or(|p| p.id != id) { @@ -164,11 +175,33 @@ impl Exchange { records.push((format!("@look/r/{slot}"), vec![])); } } + // Requests occupy stable bounded slots, but actor ordering can change. + // Explicit tombstones stop transfers after completion/cache hits/departures. + for slot in 0..skate_net::lobby::MAX_PLAYERS { + let key = format!("@look/r/{slot}"); + if !records.iter().any(|(k, _)| k == &key) { + records.push((key, vec![])); + } + } + if requests.is_empty() { + for slot in 0..WINDOW { + records.push((format!("@look/c/{slot}"), vec![])); + } + } if !requests.is_empty() { requests.sort_unstable(); requests.dedup(); - let page = requests[self.round % requests.len()]; - self.round = self.round.wrapping_add(1); + let page = if let Some((hash, page, at)) = self.serving.filter(|(hash, page, at)| { + *hash == local.hash && requests.contains(page) && now.saturating_sub(*at) < 150 + }) { + let _ = (hash, at); + page + } else { + let page = requests[self.round % requests.len()]; + self.round = self.round.wrapping_add(1); + self.serving = Some((local.hash, page, now)); + page + }; for slot in 0..WINDOW { let offset = (page * WINDOW + slot) * CHUNK; if offset >= bytes.len() { @@ -307,3 +340,97 @@ mod tests { 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(); + for s in &mut sessions { + s.set_loopback(true); + } + 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 * 10; + 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::lobby::APPLICATION) && 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 < 90000, "local model transfer 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 a8be725d..d4413c3b 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -528,6 +528,7 @@ mod online_owned_tests { .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 { @@ -544,6 +545,53 @@ mod online_owned_tests { .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(); + std::fs::write(cache.join("received-test.glb"), bytes).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()); + assert!( + !crate::animation::AnimationStatus::for_scene( + root, &names, &meshes, &nodes, &parents + ) + .unwrap() + .online_bindings() + .is_empty() + ); + 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 diff --git a/crates/skate-net/src/lobby.rs b/crates/skate-net/src/lobby.rs index e7d19ff4..9d574edd 100644 --- a/crates/skate-net/src/lobby.rs +++ b/crates/skate-net/src/lobby.rs @@ -210,7 +210,7 @@ impl Session { self.budget = if host.is_none() { DEFAULT_HOST_BUDGET } else { - DEFAULT_LINK_BUDGET + if self.loopback {2_000_000.} else {DEFAULT_LINK_BUDGET} }; self.credits = 12_000.; self.notice = if host.is_none() { @@ -221,6 +221,8 @@ impl Session { } pub fn set_loopback(&mut self, enabled: bool) { self.loopback = enabled; + self.budget=if enabled {12_000_000.} else {DEFAULT_HOST_BUDGET}; + self.set_congested(false); } pub fn is_host(&self) -> bool { self.host.is_none() @@ -230,9 +232,9 @@ impl Session { } pub fn set_congested(&mut self, congested: bool) { self.link_budget = if congested { - DEFAULT_LINK_BUDGET * 0.5 + if self.loopback {1_000_000.} else {DEFAULT_LINK_BUDGET * 0.5} } else { - DEFAULT_LINK_BUDGET + if self.loopback {2_000_000.} else {DEFAULT_LINK_BUDGET} }; } pub fn publish(&mut self, kind: u8, mut state: Packed, now: u64) { @@ -511,9 +513,9 @@ impl Session { pub fn service(&mut self, now: u64) -> Vec { let dt = now.saturating_sub(self.last_service).min(1000) as f64 / 1000.; self.last_service = now; - self.credits = (self.credits + dt * self.budget).min(12_000.); + self.credits = (self.credits + dt * self.budget).min(if self.loopback {128_000.} else {12_000.}); for l in self.links.values_mut() { - l.credits = (l.credits + dt * self.link_budget).min(3600.); + l.credits = (l.credits + dt * self.link_budget).min(if self.loopback {64_000.} else {3600.}); } if self.is_host() { let expired: Vec<_> = self diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md index 6e97067b..e86e6d77 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -10,9 +10,13 @@ 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. Updates and late joins use bounded, +other players do not need to import the model. Completed appearances are cached for +the connection: switching back sends only the identity, and acknowledged model data +stops sending. Departed receivers and completed requests are explicitly cleared. +Updates and late joins use bounded, reliable chunks through the existing host relay. Movement/collision packets keep -priority and existing bandwidth budgets. The HUD shows character transfer progress. +priority and existing internet bandwidth budgets. Loopback test connections use +a larger local budget so large files do not take minutes on one machine. The HUD shows character transfer progress. Each remote keeps its visible character until the new scenes, textures and every clothing rig are ready. Imported textures retain their own materials; retail characters use their authored clothing lighting. Remote colours/tattoos never From bf7b894af6838c0981d6181ef6880e4615e026f8 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 16:56:19 +0100 Subject: [PATCH 3/8] Reuse installed online models by content hash and pose replacement rigs before display --- crates/skate-game/src/animation.rs | 50 +++++++- crates/skate-game/src/custom_models.rs | 6 + .../skate-game/src/multiplayer/appearance.rs | 114 +++++++++++++++++- crates/skate-game/src/multiplayer/render.rs | 26 +++- docs/multiplayer-test.md | 12 +- 5 files changed, 201 insertions(+), 7 deletions(-) diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 01128cba..24b4adbc 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -16,6 +16,14 @@ 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() } @@ -197,6 +205,7 @@ fn bind( } fn present( history: Res, + skater: Res, replay: Res, time: Res>, animation: Res, @@ -209,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. @@ -280,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 6c112f9b..b005d7ad 100644 --- a/crates/skate-game/src/custom_models.rs +++ b/crates/skate-game/src/custom_models.rs @@ -101,6 +101,9 @@ pub(crate) struct CustomModels { dirty: bool, } impl CustomModels { + pub(crate) fn online_imports(&self) -> Vec<(String,PathBuf)> { + self.entries.iter().filter(|e|e.native.is_none()).map(|e|(e.asset_path("character.glb"),self.directory.join("entries").join(&e.id).join("character.glb"))).collect() + } 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 }; @@ -600,6 +603,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/multiplayer/appearance.rs b/crates/skate-game/src/multiplayer/appearance.rs index 9d60cb4c..158327b5 100644 --- a/crates/skate-game/src/multiplayer/appearance.rs +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -3,7 +3,10 @@ use super::{ Multiplayer, appearance_transfer::{Exchange, MAX_BLOB}, }; -use bevy::prelude::*; +use bevy::{ + prelude::*, + tasks::{AsyncComputeTaskPool, Task, block_on, poll_once}, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ @@ -30,6 +33,32 @@ pub(super) struct Appearances { cached: BTreeMap<[u8; 32], Look>, pub status: String, pub progress: String, + indexed: Vec, + indexing: Option>>, +} +fn index_imports( + imports: Vec<(String, PathBuf)>, +) -> Vec<(super::appearance_transfer::Identity, Look)> { + imports + .into_iter() + .filter_map(|(asset, path)| { + if std::fs::metadata(&path).ok()?.len() as usize >= MAX_BLOB { + return None; + } + let bytes = std::fs::read(path).ok()?; + validate_glb(&bytes).ok()?; + let mut hash = blake3::Hasher::new(); + hash.update(&[0]); + hash.update(&bytes); + Some(( + super::appearance_transfer::Identity { + hash: *hash.finalize().as_bytes(), + size: bytes.len() + 1, + }, + Look::Imported(asset), + )) + }) + .collect() } pub(super) fn cleanup(mut exit: MessageReader) { // Generated process-private directory, never the personal import library. @@ -61,6 +90,24 @@ pub(super) fn sync( ..default() }; } + let imports = models.online_imports(); + let keys: Vec<_> = imports.iter().map(|(key, _)| key.clone()).collect(); + if state.indexed != keys { + state.indexed = keys; + state.indexing = + Some(AsyncComputeTaskPool::get().spawn(async move { index_imports(imports) })); + } + if let Some(found) = state + .indexing + .as_mut() + .and_then(|task| block_on(poll_once(task))) + { + for (id, look) in found { + state.cached.insert(id.hash, look); + state.exchange.remember(id); + } + state.indexing = None; + } let selection = models.online_selection(); let signature = models .active @@ -113,7 +160,9 @@ pub(super) fn sync( } let now = net.started.elapsed().as_millis() as u64; let lobby = net.lobby.as_mut().unwrap(); - state.exchange.tick(lobby, now); + if state.indexing.is_none() { + 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)); @@ -364,3 +413,64 @@ mod tests { validate_glb(&std::fs::read(path).unwrap()).unwrap(); } } +#[cfg(test)] +mod local_reuse_tests { + use super::*; + #[test] + #[ignore = "requires SKATE_ONLINE_TEST_GLB"] + fn online_appearance_existing_import_needs_no_model_upload() { + let path = PathBuf::from(std::env::var("SKATE_ONLINE_TEST_GLB").unwrap()); + let found = index_imports(vec![( + "characters://owned/character.glb".into(), + path.clone(), + )]); + assert_eq!(found.len(), 1); + let (identity, look) = found[0].clone(); + assert!(matches!(look, Look::Imported(_))); + let mut bytes = vec![0]; + bytes.extend(std::fs::read(path).unwrap()); + assert_eq!(identity.hash, *blake3::hash(&bytes).as_bytes()); + let mut sessions: Vec<_> = (0..2) + .map(|i| { + skate_net::lobby::Session::new( + 3, + 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 transfer = [Exchange::default(), Exchange::default()]; + transfer[0].publish(bytes); + transfer[1].publish(vec![1]); + transfer[1].remember(identity.clone()); + for step in 0..200 { + let now = step * 10; + let mut wire = vec![]; + for i in 0..2 { + transfer[i].tick(&mut sessions[i], now); + for packet in sessions[i].service(now) { + assert!( + packet.data.len() < 200, + "local content must not send model chunks" + ); + wire.push((i + 1, packet)); + } + } + for (from, p) in wire { + sessions[p.peer as usize - 1].receive(from as u64, &p.data, now); + } + } + let (received, payload) = transfer[1] + .ready + .get(&10) + .expect("local appearance must be ready after handshake"); + assert_eq!(received, &identity); + assert!(payload.is_empty()); + } +} diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index d4413c3b..a98db69b 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -236,6 +236,7 @@ pub(super) fn spawn( fn bind( mut commands: Commands, + net: Res, mut skins: ResMut, skater: Res, meshes: Query<(Entity, &SkinnedMesh)>, @@ -249,7 +250,21 @@ fn bind( pieces: Query<&OutfitPiece>, mut morphs: Query<(Entity, &mut MorphWeights)>, ) { - for skin in skins.actors.values_mut() { + 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; }; @@ -345,6 +360,15 @@ fn bind( )); } } + 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(); } diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md index e86e6d77..ecb02d02 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -10,15 +10,21 @@ 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. Completed appearances are cached for +other players do not need to import the model. Installed imports are indexed on a +background worker by content hash. When a matching file is already available, the +receiver loads that local asset and requests no model data, including when a +different character is currently equipped. Completed appearances are cached for the connection: switching back sends only the identity, and acknowledged model data stops sending. Departed receivers and completed requests are explicitly cleared. Updates and late joins use bounded, reliable chunks through the existing host relay. Movement/collision packets keep priority and existing internet bandwidth budgets. Loopback test connections use -a larger local budget so large files do not take minutes on one machine. The HUD shows character transfer progress. +a larger local bandwidth budget. The HUD shows character transfer progress. Each remote keeps its visible character until the new scenes, textures and every -clothing rig are ready. Imported textures retain their own materials; retail +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. From e1b3ab1bc83eef029ff98ea6c22434ef98f32ad2 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 17:21:28 +0100 Subject: [PATCH 4/8] Stream online character assets reliably without local file shortcuts --- Cargo.lock | 12 + crates/skate-game/src/custom_models.rs | 3 - .../skate-game/src/multiplayer/appearance.rs | 114 +---- .../src/multiplayer/appearance_transfer.rs | 400 ++++++++------- crates/skate-game/src/multiplayer/render.rs | 35 +- .../skate-game/src/multiplayer/transport.rs | 8 +- crates/skate-net/Cargo.toml | 2 + crates/skate-net/src/blob.rs | 455 ++++++++++++++++++ crates/skate-net/src/lib.rs | 2 + crates/skate-net/src/lobby.rs | 31 +- crates/skate-net/src/socket.rs | 8 + crates/skate-steam-relay/src/main.rs | 21 +- docs/multiplayer-test.md | 50 +- 13 files changed, 776 insertions(+), 365 deletions(-) create mode 100644 crates/skate-net/src/blob.rs create mode 100644 crates/skate-net/src/socket.rs diff --git a/Cargo.lock b/Cargo.lock index cff621a6..d8b5c6fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4759,10 +4759,12 @@ dependencies = [ name = "skate-net" version = "0.1.0" dependencies = [ + "blake3", "half", "serde", "serde_json", "skate-core", + "socket2", ] [[package]] @@ -4857,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/src/custom_models.rs b/crates/skate-game/src/custom_models.rs index b005d7ad..89c87f7c 100644 --- a/crates/skate-game/src/custom_models.rs +++ b/crates/skate-game/src/custom_models.rs @@ -101,9 +101,6 @@ pub(crate) struct CustomModels { dirty: bool, } impl CustomModels { - pub(crate) fn online_imports(&self) -> Vec<(String,PathBuf)> { - self.entries.iter().filter(|e|e.native.is_none()).map(|e|(e.asset_path("character.glb"),self.directory.join("entries").join(&e.id).join("character.glb"))).collect() - } 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 }; diff --git a/crates/skate-game/src/multiplayer/appearance.rs b/crates/skate-game/src/multiplayer/appearance.rs index 158327b5..9d60cb4c 100644 --- a/crates/skate-game/src/multiplayer/appearance.rs +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -3,10 +3,7 @@ use super::{ Multiplayer, appearance_transfer::{Exchange, MAX_BLOB}, }; -use bevy::{ - prelude::*, - tasks::{AsyncComputeTaskPool, Task, block_on, poll_once}, -}; +use bevy::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ @@ -33,32 +30,6 @@ pub(super) struct Appearances { cached: BTreeMap<[u8; 32], Look>, pub status: String, pub progress: String, - indexed: Vec, - indexing: Option>>, -} -fn index_imports( - imports: Vec<(String, PathBuf)>, -) -> Vec<(super::appearance_transfer::Identity, Look)> { - imports - .into_iter() - .filter_map(|(asset, path)| { - if std::fs::metadata(&path).ok()?.len() as usize >= MAX_BLOB { - return None; - } - let bytes = std::fs::read(path).ok()?; - validate_glb(&bytes).ok()?; - let mut hash = blake3::Hasher::new(); - hash.update(&[0]); - hash.update(&bytes); - Some(( - super::appearance_transfer::Identity { - hash: *hash.finalize().as_bytes(), - size: bytes.len() + 1, - }, - Look::Imported(asset), - )) - }) - .collect() } pub(super) fn cleanup(mut exit: MessageReader) { // Generated process-private directory, never the personal import library. @@ -90,24 +61,6 @@ pub(super) fn sync( ..default() }; } - let imports = models.online_imports(); - let keys: Vec<_> = imports.iter().map(|(key, _)| key.clone()).collect(); - if state.indexed != keys { - state.indexed = keys; - state.indexing = - Some(AsyncComputeTaskPool::get().spawn(async move { index_imports(imports) })); - } - if let Some(found) = state - .indexing - .as_mut() - .and_then(|task| block_on(poll_once(task))) - { - for (id, look) in found { - state.cached.insert(id.hash, look); - state.exchange.remember(id); - } - state.indexing = None; - } let selection = models.online_selection(); let signature = models .active @@ -160,9 +113,7 @@ pub(super) fn sync( } let now = net.started.elapsed().as_millis() as u64; let lobby = net.lobby.as_mut().unwrap(); - if state.indexing.is_none() { - state.exchange.tick(lobby, now); - } + 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)); @@ -413,64 +364,3 @@ mod tests { validate_glb(&std::fs::read(path).unwrap()).unwrap(); } } -#[cfg(test)] -mod local_reuse_tests { - use super::*; - #[test] - #[ignore = "requires SKATE_ONLINE_TEST_GLB"] - fn online_appearance_existing_import_needs_no_model_upload() { - let path = PathBuf::from(std::env::var("SKATE_ONLINE_TEST_GLB").unwrap()); - let found = index_imports(vec![( - "characters://owned/character.glb".into(), - path.clone(), - )]); - assert_eq!(found.len(), 1); - let (identity, look) = found[0].clone(); - assert!(matches!(look, Look::Imported(_))); - let mut bytes = vec![0]; - bytes.extend(std::fs::read(path).unwrap()); - assert_eq!(identity.hash, *blake3::hash(&bytes).as_bytes()); - let mut sessions: Vec<_> = (0..2) - .map(|i| { - skate_net::lobby::Session::new( - 3, - 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 transfer = [Exchange::default(), Exchange::default()]; - transfer[0].publish(bytes); - transfer[1].publish(vec![1]); - transfer[1].remember(identity.clone()); - for step in 0..200 { - let now = step * 10; - let mut wire = vec![]; - for i in 0..2 { - transfer[i].tick(&mut sessions[i], now); - for packet in sessions[i].service(now) { - assert!( - packet.data.len() < 200, - "local content must not send model chunks" - ); - wire.push((i + 1, packet)); - } - } - for (from, p) in wire { - sessions[p.peer as usize - 1].receive(from as u64, &p.data, now); - } - } - let (received, payload) = transfer[1] - .ready - .get(&10) - .expect("local appearance must be ready after handshake"); - assert_eq!(received, &identity); - assert!(payload.is_empty()); - } -} diff --git a/crates/skate-game/src/multiplayer/appearance_transfer.rs b/crates/skate-game/src/multiplayer/appearance_transfer.rs index e64f0b00..c678a62e 100644 --- a/crates/skate-game/src/multiplayer/appearance_transfer.rs +++ b/crates/skate-game/src/multiplayer/appearance_transfer.rs @@ -1,222 +1,191 @@ -//! Bounded appearance transfer over the lobby's authenticated, budgeted record stream. -//! Fixed keys keep late joins/retries reliable without growing lobby state per file. +//! 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; -const META: &str = "@look/meta"; -const CHUNK: usize = 980; -const WINDOW: usize = 32; -pub const MAX_BLOB: usize = 64 * 1024 * 1024; -const SESSION_BUDGET: usize = 256 * 1024 * 1024; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Identity { - pub hash: [u8; 32], - pub size: usize, -} -impl Identity { - fn read(b: &[u8]) -> Option { - if b.len() != 36 { - return None; - } - let size = u32::from_le_bytes(b[32..].try_into().ok()?) as usize; - (size > 0 && size <= MAX_BLOB).then(|| Self { - hash: b[..32].try_into().unwrap(), - size, - }) - } - fn bytes(&self) -> Vec { - let mut b = self.hash.to_vec(); - b.extend((self.size as u32).to_le_bytes()); - b - } -} -struct Incoming { - id: Identity, - bytes: Vec, - received: Vec, -} +use std::collections::{BTreeMap, BTreeSet}; #[derive(Default)] pub struct Exchange { - local: Option<(Identity, Vec)>, - incoming: BTreeMap, + pending: Option>, pub ready: BTreeMap)>, - spent: usize, - known: BTreeMap<[u8; 32], Identity>, - round: usize, - next: u64, - serving: Option<([u8; 32], usize, u64)>, + known: BTreeSet<[u8; 32]>, + progress: (usize, usize), } impl Exchange { pub fn remember(&mut self, id: Identity) { - self.known.insert(id.hash, id); - } - pub fn progress(&self) -> String { - let total: usize = self.incoming.values().map(|p| p.received.len()).sum(); - let done: usize = self - .incoming - .values() - .map(|p| p.received.iter().filter(|v| **v).count()) - .sum(); - if total == 0 { - format!("Characters: {} synced", self.ready.len()) - } else { - format!( - "Characters: {} synced | downloading {} ({}%)", - self.ready.len(), - self.incoming.len(), - done * 100 / total - ) - } + self.known.insert(id.hash); } pub fn publish(&mut self, bytes: Vec) -> bool { if bytes.is_empty() || bytes.len() > MAX_BLOB { return false; } - let id = Identity { - hash: *blake3::hash(&bytes).as_bytes(), - size: bytes.len(), - }; - self.local = Some((id, bytes)); + self.pending = Some(bytes); true } - pub fn tick(&mut self, session: &mut Session, now: u64) { - if now < self.next { - return; - } - self.next = now + 25; - self.incoming - .retain(|id, _| session.actors.contains_key(id)); - self.ready.retain(|id, _| session.actors.contains_key(id)); - let Some((local, bytes)) = &self.local else { - return; - }; - session.publish_application(META, local.bytes(), now); - let mut records = vec![]; - let mut requests = vec![]; - for (slot, (&actor, a)) in session.actors.iter().enumerate() { - if actor == session.local { - continue; - } - for r in a - .application - .iter() - .filter(|(key, _)| key.starts_with("@look/r/")) - .map(|(_, r)| r) - { - if r.value.len() == 44 - && u64::from_le_bytes(r.value[..8].try_into().unwrap()) == session.local - && r.value[8..40] == local.hash - { - let page = u32::from_le_bytes(r.value[40..].try_into().unwrap()) as usize; - if page < bytes.len().div_ceil(CHUNK * WINDOW) { - requests.push(page); - } - } - } - let Some(id) = a - .application - .get(META) - .and_then(|r| Identity::read(&r.value)) - else { - continue; - }; - if self.ready.get(&actor).is_some_and(|(old, _)| old == &id) { - self.incoming.remove(&actor); - continue; - } - if self.known.get(&id.hash) == Some(&id) { - self.incoming.remove(&actor); - self.ready.insert(actor, (id, vec![])); - continue; - } - if self.incoming.get(&actor).is_none_or(|p| p.id != id) { - if self.spent.saturating_add(id.size) > SESSION_BUDGET { - continue; - } - self.spent += id.size; - self.incoming.insert( - actor, - Incoming { - received: vec![false; id.size.div_ceil(CHUNK)], - bytes: vec![0; id.size], - id, - }, - ); - } - let p = self.incoming.get_mut(&actor).unwrap(); - for i in 0..WINDOW { - let Some(r) = a.application.get(&format!("@look/c/{i}")) else { - continue; - }; - let b = &r.value; - if b.len() < 37 || b[..32] != p.id.hash { - continue; - } - let offset = u32::from_le_bytes(b[32..36].try_into().unwrap()) as usize; - if offset % CHUNK != 0 - || offset >= p.bytes.len() - || b.len() - 36 != CHUNK.min(p.bytes.len() - offset) - { - continue; - } - p.bytes[offset..offset + b.len() - 36].copy_from_slice(&b[36..]); - p.received[offset / CHUNK] = true; - } - if let Some(missing) = p.received.iter().position(|done| !*done) { - let mut request = actor.to_le_bytes().to_vec(); - request.extend(p.id.hash); - request.extend(((missing / WINDOW) as u32).to_le_bytes()); - records.push((format!("@look/r/{slot}"), request)); - } else { - let p = self.incoming.remove(&actor).unwrap(); - if blake3::hash(&p.bytes).as_bytes() == &p.id.hash { - self.ready.insert(actor, (p.id, p.bytes)); - } - // Clear the request after completion, including corrupted transfers. - records.push((format!("@look/r/{slot}"), vec![])); - } - } - // Requests occupy stable bounded slots, but actor ordering can change. - // Explicit tombstones stop transfers after completion/cache hits/departures. - for slot in 0..skate_net::lobby::MAX_PLAYERS { - let key = format!("@look/r/{slot}"); - if !records.iter().any(|(k, _)| k == &key) { - records.push((key, vec![])); - } - } - if requests.is_empty() { - for slot in 0..WINDOW { - records.push((format!("@look/c/{slot}"), vec![])); - } - } - if !requests.is_empty() { - requests.sort_unstable(); - requests.dedup(); - let page = if let Some((hash, page, at)) = self.serving.filter(|(hash, page, at)| { - *hash == local.hash && requests.contains(page) && now.saturating_sub(*at) < 150 - }) { - let _ = (hash, at); - page - } else { - let page = requests[self.round % requests.len()]; - self.round = self.round.wrapping_add(1); - self.serving = Some((local.hash, page, now)); - page - }; - for slot in 0..WINDOW { - let offset = (page * WINDOW + slot) * CHUNK; - if offset >= bytes.len() { - break; - } - let mut b = local.hash.to_vec(); - b.extend((offset as u32).to_le_bytes()); - b.extend(&bytes[offset..(offset + CHUNK).min(bytes.len())]); - records.push((format!("@look/c/{slot}"), b)); - } - } - for (key, value) in records { - session.publish_application(&key, value, now); + 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 { @@ -251,6 +220,7 @@ mod tests { 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 }; @@ -289,6 +259,13 @@ mod tests { } 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); @@ -330,12 +307,11 @@ mod tests { } #[test] fn online_appearance_rejects_unbounded_metadata_and_chunks() { - assert!(Identity::read(&[]).is_none()); let id = Identity { hash: [1; 32], size: MAX_BLOB + 1, }; - assert!(Identity::read(&id.bytes()).is_none()); + assert!(id.size > MAX_BLOB); let mut e = Exchange::default(); assert!(!e.publish(vec![])); } @@ -360,9 +336,7 @@ mod delivery_tests { ) }) .collect(); - for s in &mut sessions { - s.set_loopback(true); - } + 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()); @@ -372,13 +346,12 @@ mod delivery_tests { let mut last_model_packet = 0; let mut reused = false; for step in 0..18000 { - let now = step * 10; + 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::lobby::APPLICATION) && p.data.len() > 100 - { + 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"); } @@ -396,7 +369,10 @@ mod delivery_tests { .get(&10) .is_some_and(|(_, b)| b == &model) { - assert!(now < 90000, "local model transfer is too slow: {now} ms"); + 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(); diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index a98db69b..16cb074a 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -373,6 +373,8 @@ fn bind( 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; @@ -574,7 +576,12 @@ mod online_owned_tests { super::super::appearance::validate_glb(&bytes).unwrap(); let cache = super::super::appearance::cache_directory(); std::fs::create_dir_all(cache).unwrap(); - std::fs::write(cache.join("received-test.glb"), bytes).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::() @@ -604,14 +611,24 @@ mod online_owned_tests { Query<&ChildOf>, )> = SystemState::new(app.world_mut()); let (meshes, nodes, parents) = queries.get(app.world()); - assert!( - !crate::animation::AnimationStatus::for_scene( - root, &names, &meshes, &nodes, &parents - ) - .unwrap() - .online_bindings() - .is_empty() - ); + 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(); 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-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 9d574edd..91112f2e 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 @@ -210,7 +212,7 @@ impl Session { self.budget = if host.is_none() { DEFAULT_HOST_BUDGET } else { - if self.loopback {2_000_000.} else {DEFAULT_LINK_BUDGET} + DEFAULT_LINK_BUDGET }; self.credits = 12_000.; self.notice = if host.is_none() { @@ -221,8 +223,6 @@ impl Session { } pub fn set_loopback(&mut self, enabled: bool) { self.loopback = enabled; - self.budget=if enabled {12_000_000.} else {DEFAULT_HOST_BUDGET}; - self.set_congested(false); } pub fn is_host(&self) -> bool { self.host.is_none() @@ -231,10 +231,11 @@ 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 { - if self.loopback {1_000_000.} else {DEFAULT_LINK_BUDGET * 0.5} + DEFAULT_LINK_BUDGET * 0.5 } else { - if self.loopback {2_000_000.} else {DEFAULT_LINK_BUDGET} + DEFAULT_LINK_BUDGET }; } pub fn publish(&mut self, kind: u8, mut state: Packed, now: u64) { @@ -381,6 +382,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; } @@ -513,9 +527,9 @@ impl Session { pub fn service(&mut self, now: u64) -> Vec { let dt = now.saturating_sub(self.last_service).min(1000) as f64 / 1000.; self.last_service = now; - self.credits = (self.credits + dt * self.budget).min(if self.loopback {128_000.} else {12_000.}); + self.credits = (self.credits + dt * self.budget).min(12_000.); for l in self.links.values_mut() { - l.credits = (l.credits + dt * self.link_budget).min(if self.loopback {64_000.} else {3600.}); + l.credits = (l.credits + dt * self.link_budget).min(3600.); } if self.is_host() { let expired: Vec<_> = self @@ -699,6 +713,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/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/multiplayer-test.md b/docs/multiplayer-test.md index ecb02d02..a6c3a187 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -10,16 +10,18 @@ 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. Installed imports are indexed on a -background worker by content hash. When a matching file is already available, the -receiver loads that local asset and requests no model data, including when a -different character is currently equipped. Completed appearances are cached for -the connection: switching back sends only the identity, and acknowledged model data -stops sending. Departed receivers and completed requests are explicitly cleared. -Updates and late joins use bounded, -reliable chunks through the existing host relay. Movement/collision packets keep -priority and existing internet bandwidth budgets. Loopback test connections use -a larger local bandwidth budget. The HUD shows character transfer progress. +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 @@ -32,7 +34,8 @@ 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. -A connection accepts at most 256 MiB of new appearance payloads. Temporary imports +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. @@ -131,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`). From e6a1dfe79c138f3ffc6f6cec02e7740870d0d6c5 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 17:37:15 +0100 Subject: [PATCH 5/8] Enable Steam SDK configuration bindings for model upload bandwidth --- crates/skate-steam-relay/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From 983771f99b2da224c5f1e79321dd8abb1a2ca6ba Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 17:56:21 +0100 Subject: [PATCH 6/8] Restore authored stock surface detail and specular masks after model swaps --- crates/skate-game/src/customiser_parts.rs | 23 +++++++++++++++---- crates/skate-game/src/customiser_stamp.wgsl | 18 ++++++++++++--- crates/skate-game/src/retail_character.wgsl | 23 ++++--------------- .../src/retail_character_common.wgsl | 20 ++++++++++++++++ docs/character-lighting-investigation.md | 19 +++++++++++++++ 5 files changed, 76 insertions(+), 27 deletions(-) diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index 29e950f8..dbff60db 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -430,6 +430,9 @@ impl Parts { 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(); @@ -472,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.shader.starts_with("character.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() { @@ -814,11 +817,21 @@ mod online_tests { 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::default(),extension:SkinStamp::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()); 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/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/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index 0cd22c69..cafc253e 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -181,3 +181,22 @@ 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. From aeb641c52c02ec31348955d9f4eb2a05d03dc0a3 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 18:43:53 +0100 Subject: [PATCH 7/8] Recognize pro skater hair materials instead of applying clothing highlights --- crates/skate-game/src/customiser_parts.rs | 2 +- crates/skate-game/src/retail_character.rs | 25 ++++++++++++++++++++++- docs/character-lighting-investigation.md | 13 ++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index dbff60db..c1794070 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -476,7 +476,7 @@ impl Parts { 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::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.shader.starts_with("character.hair"))), + 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(); diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 54a94a80..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)] @@ -275,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 @@ -358,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(); @@ -376,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/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index cafc253e..aa76c326 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -200,3 +200,16 @@ 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. From e008f9ec505ece67f0c552c7308f48e1083ebe12 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 18:51:31 +0100 Subject: [PATCH 8/8] Verify stock hair materials retain authored shading after profile restoration --- crates/skate-game/src/customiser_parts.rs | 41 +++++++++++++++++++++++ docs/character-lighting-investigation.md | 8 +++++ 2 files changed, 49 insertions(+) diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index c1794070..1eb151ac 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -837,3 +837,44 @@ mod online_tests { 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/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index aa76c326..5c9ac079 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -213,3 +213,11 @@ 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.