mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Synchronize online outfits and native characters with bounded custom model transfer
This commit is contained in:
1 parent
f55c17dc4b
commit
f8e10c8ec9
11 files changed
+1154
-138
No files matched your search
Generated
+2
@@ -4731,7 +4731,9 @@ name = "skate-game"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"blake3",
|
||||
"half",
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skate-core",
|
||||
|
||||
@@ -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"] }
|
||||
@@ -16,6 +16,10 @@ struct BoneBinding {
|
||||
parent_bone: Option<usize>,
|
||||
}
|
||||
impl AnimationStatus {
|
||||
pub(crate) fn online_bindings(&self) -> Vec<(Entity,usize,Option<usize>)> {
|
||||
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,
|
||||
|
||||
@@ -101,6 +101,14 @@ pub(crate) struct CustomModels {
|
||||
dirty: bool,
|
||||
}
|
||||
impl CustomModels {
|
||||
pub(crate) fn online_selection(&self) -> Option<(Option<String>, 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<String> {
|
||||
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",
|
||||
|
||||
@@ -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<SkaterMaterial>) -> Option<SkaterMaterial> {
|
||||
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<f32>=part.flag(key).split(',').filter_map(|n|n.parse().ok()).collect();
|
||||
if let Ok(bounds)=<[f32;4]>::try_from(bounds) {
|
||||
let (transform,rectangle)=crate::customiser_material::placement(bounds,t.bounds);
|
||||
extension.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<SkaterMaterial>) {
|
||||
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<f32> = part
|
||||
.flag(key)
|
||||
.split(',')
|
||||
.filter_map(|n| n.parse().ok())
|
||||
.collect();
|
||||
if let Ok(bounds) = <[f32; 4]>::try_from(bounds) {
|
||||
let (transform, rectangle) =
|
||||
crate::customiser_material::placement(bounds, t.bounds);
|
||||
extension = SkinStamp {
|
||||
texture: parts.tattoos.get(tid).cloned(),
|
||||
transform,
|
||||
rectangle,
|
||||
enabled: Vec4::X,
|
||||
..default()
|
||||
};
|
||||
}
|
||||
}
|
||||
let changed = materials.get(&handle).is_some_and(|m| {
|
||||
m.extension.texture != extension.texture
|
||||
|| m.extension.transform != extension.transform
|
||||
|| m.extension.enabled != extension.enabled
|
||||
});
|
||||
if changed {
|
||||
if let Some(m) = materials.get_mut(&handle) {
|
||||
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::<Image>::default();let image=images.add(Image::default());parts.tattoos.insert("ink".into(),image.clone());
|
||||
let mut materials=Assets::<SkaterMaterial>::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());
|
||||
}
|
||||
}
|
||||
@@ -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<u8>)> {
|
||||
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<u8>) -> 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::<appearance::Appearances>()
|
||||
.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<Multiplayer>) {
|
||||
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<Multiplayer>) {
|
||||
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<GamePhysics>,
|
||||
mut label: Single<&mut Text, With<NetworkHud>>,
|
||||
mods: Res<crate::modding::network::ModSync>,
|
||||
appearances: Res<appearance::Appearances>,
|
||||
) {
|
||||
***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 {
|
||||
|
||||
@@ -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<u64, ([u8; 32], Look)>,
|
||||
seen: BTreeMap<u64, [u8; 32]>,
|
||||
pub status: String,
|
||||
pub progress: String,
|
||||
}
|
||||
pub(super) fn cleanup(mut exit: MessageReader<AppExit>) {
|
||||
// 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<PathBuf> = OnceLock::new();
|
||||
ROOT.get_or_init(|| std::env::temp_dir().join(format!("skate-online-{:016x}", super::unique())))
|
||||
}
|
||||
pub(super) fn sync(
|
||||
mut state: ResMut<Appearances>,
|
||||
mut net: ResMut<Multiplayer>,
|
||||
models: Res<crate::custom_models::CustomModels>,
|
||||
parts: Res<crate::customiser_parts::Parts>,
|
||||
) {
|
||||
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<Vec<u8>, 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<Look, String> {
|
||||
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<u8> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<u8> {
|
||||
let mut b = self.hash.to_vec();
|
||||
b.extend((self.size as u32).to_le_bytes());
|
||||
b
|
||||
}
|
||||
}
|
||||
struct Incoming {
|
||||
id: Identity,
|
||||
bytes: Vec<u8>,
|
||||
received: Vec<bool>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Exchange {
|
||||
local: Option<(Identity, Vec<u8>)>,
|
||||
incoming: BTreeMap<u64, Incoming>,
|
||||
pub ready: BTreeMap<u64, (Identity, Vec<u8>)>,
|
||||
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<u8>) -> 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::<Vec<_>>()
|
||||
})
|
||||
.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![]));
|
||||
}
|
||||
}
|
||||
@@ -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<Entity>,
|
||||
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<u64, RemoteSkin>,
|
||||
rest: Vec<Mat4>,
|
||||
@@ -13,6 +30,10 @@ struct RemoteSkins {
|
||||
struct RemoteSkin {
|
||||
root: Option<Entity>,
|
||||
bindings: Vec<(Entity, usize, Option<usize>)>,
|
||||
visible: Option<Entity>,
|
||||
pending: Option<Candidate>,
|
||||
requested: Option<[u8; 32]>,
|
||||
|
||||
positions: Buffer<[f32; 3]>,
|
||||
roots: Buffer<Transform>,
|
||||
poses: Buffer<Vec<Transform>>,
|
||||
@@ -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<Multiplayer>,
|
||||
looks: Res<Appearances>,
|
||||
mut skins: ResMut<RemoteSkins>,
|
||||
server: Res<AssetServer>,
|
||||
parts: Res<crate::customiser_parts::Parts>,
|
||||
models: Res<crate::custom_models::CustomModels>,
|
||||
) {
|
||||
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<RemoteSkins>,
|
||||
skater: Res<SkaterRuntime>,
|
||||
meshes: Query<(Entity, &SkinnedMesh)>,
|
||||
names: Query<&Name>,
|
||||
nodes: Query<(&Name, &Transform)>,
|
||||
parents: Query<&ChildOf>,
|
||||
instances: Query<&SceneInstance>,
|
||||
spawner: Res<SceneSpawner>,
|
||||
server: Res<AssetServer>,
|
||||
mut parts: ResMut<crate::customiser_parts::Parts>,
|
||||
mut materials: ResMut<Assets<SkaterMaterial>>,
|
||||
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::<MeshMaterial3d<StandardMaterial>>()
|
||||
.insert(MeshMaterial3d(handle.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let weights: Vec<f32> = 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::<StandardMaterial>()
|
||||
.init_asset::<AnimationClip>()
|
||||
.register_type::<MeshMaterial3d<StandardMaterial>>();
|
||||
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::<AssetServer>()
|
||||
.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::<SceneInstance>(e)
|
||||
.is_some_and(|i| world.resource::<SceneSpawner>().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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,9 +235,10 @@ fn bind(
|
||||
mut materials: ResMut<Assets<CharacterMaterial>>,
|
||||
entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d<StandardMaterial>, Option<&RenderLayers>)>,
|
||||
parents: Query<&ChildOf>,
|
||||
players: Query<(), With<crate::world::PlayerRoot>>,
|
||||
players: Query<(), Or<(With<crate::world::PlayerRoot>, With<crate::multiplayer::appearance::RemoteCharacter>)>>,
|
||||
parts: Query<(), With<crate::customiser_parts::PartRoot>>,
|
||||
native: Query<&crate::custom_models::NativeModelRoot>,
|
||||
imports: Query<(), With<crate::custom_models::CustomModelRoot>>,
|
||||
) {
|
||||
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) };
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user