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