Synchronize online outfits and native characters with bounded custom model transfer

This commit is contained in:
chazmm committed 2026-09-09 16:22:44 +01:00
1 parent f55c17dc4b
commit f8e10c8ec9
11 files changed
+1154 -138

No files matched your search

Generated
+2
View File
@@ -4731,7 +4731,9 @@ name = "skate-game"
version = "0.1.0"
dependencies = [
"bevy",
"blake3",
"half",
"image",
"serde",
"serde_json",
"skate-core",
+2
View File
@@ -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"] }
+4
View File
@@ -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,
+10
View File
@@ -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",
+65 -76
View File
@@ -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());
}
}
+12 -6
View File
@@ -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![]));
}
}
+374 -55
View File
@@ -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();
}
}
}
+3 -1
View File
@@ -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) };
+35
View File
@@ -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`.