Merge character transfer and stock material fixes

This commit is contained in:
chazmm committed 2026-09-09 18:56:46 +01:00
commit 7bd0ba9435
23 files changed
+2143 -179

No files matched your search

Generated
+14
View File
@@ -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"
+2
View File
@@ -33,3 +33,5 @@ skate-mods = { path = "../skate-mods" }
skate-vehicles = { path = "../skate-vehicles" }
skate-net = { path = "../skate-net" }
blake3 = "=1.8.7"
image = { version = "=0.25.10", default-features = false, features = ["png", "jpeg"] }
+53 -1
View File
@@ -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));
}
}
+13
View File
@@ -101,6 +101,14 @@ pub(crate) struct CustomModels {
dirty: bool,
}
impl CustomModels {
pub(crate) fn online_selection(&self) -> Option<(Option<String>, PathBuf)> {
let e = self.entries.iter().find(|e| Some(&e.id)==self.active.as_ref())?;
let directory = if e.asset_prefix.is_empty() { &self.directory } else { &self.native_directory };
Some((e.native.as_ref().map(|n|n.key.clone()),directory.join("entries").join(&e.id).join("character.glb")))
}
pub(crate) fn online_native_path(&self, key: &str) -> Option<String> {
self.entries.iter().find(|e|e.native.as_ref().is_some_and(|n|n.key==key)).map(|e|e.asset_path("character.glb"))
}
pub(crate) fn native_style(&self) -> Option<&'static str> {
self.active
.as_ref()
@@ -135,6 +143,8 @@ pub(crate) fn library_path() -> PathBuf {
.join("Skate3RustEngine/custom-characters")
}
pub(crate) fn register_source(app: &mut App) {
let cache = crate::multiplayer::appearance::cache_directory().to_owned();
app.register_asset_source("online-characters", AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(cache.clone()))));
let directory = library_path();
app.register_asset_source(
"characters",
@@ -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)) {
+123 -80
View File
@@ -110,6 +110,12 @@ impl Parts {
}
}
}
if let Some(colours) = profile["colours"].as_object() {
for value in colours.values() {
let rgb=serde_json::from_value::<[f32;3]>(value.clone()).map_err(|_|"Invalid clothing colour")?;
if rgb.iter().any(|v|!(0.0..=1.0).contains(v)) {return Err("Invalid clothing colour".into());}
}
}
for key in ["skin_tint", "hair_tint"] {
if let Some(value) = profile.get(key) {
let v = serde_json::from_value::<[f32; 3]>(value.clone())
@@ -406,6 +412,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());
}
}
+15 -3
View File
@@ -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);
+12 -6
View File
@@ -1,5 +1,7 @@
//! Transport-neutral ten-player free-skate; each player owns their simulation.
mod render;
pub(crate) mod appearance;
mod appearance_transfer;
mod transport;
use crate::{
app::{FrameSet, SimulationSet},
@@ -101,9 +103,10 @@ impl Multiplayer {
}
pub(crate) fn mod_records(&self) -> Vec<(u64, String, u32, Vec<u8>)> {
self.lobby.as_ref().map_or_else(Vec::new, |l| l.actors.iter().filter(|(id, _)| **id != l.local)
.flat_map(|(&id, actor)| actor.application.iter().map(move |(key,r)| (id,key.clone(),r.seq,r.value.clone()))).collect())
.flat_map(|(&id, actor)| actor.application.iter().filter(|(key,_)|!key.starts_with("@look/")).map(move |(key,r)| (id,key.clone(),r.seq,r.value.clone()))).collect())
}
pub(crate) fn publish_mod(&mut self, key: &str, value: Vec<u8>) -> bool {
if key.starts_with("@look/") {return false;}
let now = self.started.elapsed().as_millis() as u64;
self.lobby.as_mut().is_some_and(|l| l.publish_application(key,value,now))
}
@@ -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);
}
}
+464 -56
View File
@@ -1,8 +1,25 @@
use super::appearance::{Appearances, Look, RemoteCharacter};
use super::*;
use bevy::mesh::skinning::SkinnedMesh;
use crate::customiser_material::SkaterMaterial;
use bevy::{
mesh::{morph::MorphWeights, skinning::SkinnedMesh},
scene::SceneInstance,
};
struct Candidate {
root: Entity,
scenes: Vec<Entity>,
look: Look,
started: Instant,
}
#[derive(Component)]
struct OutfitPiece {
id: String,
mid: String,
}
use skate_net::interpolation::{Buffer, Clock, position};
#[derive(Resource)]
struct RemoteSkins {
pub(super) struct RemoteSkins {
reported: f64,
actors: BTreeMap<u64, RemoteSkin>,
rest: Vec<Mat4>,
@@ -13,6 +30,10 @@ struct RemoteSkins {
struct RemoteSkin {
root: Option<Entity>,
bindings: Vec<(Entity, usize, Option<usize>)>,
visible: Option<Entity>,
pending: Option<Candidate>,
requested: Option<[u8; 32]>,
positions: Buffer<[f32; 3]>,
roots: Buffer<Transform>,
poses: Buffer<Vec<Transform>>,
@@ -59,7 +80,13 @@ impl Plugin for RemoteRenderPlugin {
.iter()
.map(|&p| p as i32)
.collect();
let board=skater.animation.evaluator.frames.bone_names.iter().position(|n|n=="SKATEBOARD_ROOT");
let board = skater
.animation
.evaluator
.frames
.bone_names
.iter()
.position(|n| n == "SKATEBOARD_ROOT");
app.insert_resource(RemoteSkins {
board,
reported: 0.,
@@ -69,15 +96,20 @@ impl Plugin for RemoteRenderPlugin {
})
.add_systems(
Update,
(spawn, bind, present).chain().after(crate::modding::vehicles::present),
(spawn, bind, present)
.chain()
.after(crate::modding::vehicles::present),
);
}
}
fn spawn(
pub(super) fn spawn(
mut commands: Commands,
net: Res<Multiplayer>,
looks: Res<Appearances>,
mut skins: ResMut<RemoteSkins>,
server: Res<AssetServer>,
parts: Res<crate::customiser_parts::Parts>,
models: Res<crate::custom_models::CustomModels>,
) {
let removed: Vec<_> = skins
.actors
@@ -90,77 +122,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) {
+27 -2
View File
@@ -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());
+4 -19
View File
@@ -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 {
+2
View File
@@ -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
+455
View File
@@ -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
}
}
+2
View File
@@ -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;
+19
View File
@@ -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
}
+8
View File
@@ -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)
}
+1 -1
View File
@@ -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" }
+17 -4
View File
@@ -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()
+40
View File
@@ -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.
+70 -3
View File
@@ -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`).