Merge Steam P2P multiplayer with character menu and map transitions

This commit is contained in:
chazmm committed 2026-09-08 18:09:13 +01:00
commit 5732b1e0af
48 files changed
+5992 -97

No files matched your search

+6
View File
@@ -8,6 +8,11 @@ publish.workspace = true
name = "skate3rust"
path = "src/main.rs"
[[bin]]
name = "skate3-multiplayer"
path = "src/multiplayer_main.rs"
test = false
[features]
default = ["dev-dynamic"]
dev-dynamic = ["bevy/dynamic_linking"]
@@ -19,3 +24,4 @@ skate-data.workspace = true
half = "=2.7.1"
serde_json.workspace = true
serde.workspace = true
skate-net = { path = "../skate-net" }
+3 -1
View File
@@ -47,7 +47,7 @@ pub(crate) fn build(
})
.set(WindowPlugin {
primary_window: Some(Window {
title: "Skate 3 Rust Engine".into(),
title: config.multiplayer.title.clone().unwrap_or_else(||"Skate 3 Rust Engine".into()),
resolution: (1280, 800).into(),
..default()
}),
@@ -64,6 +64,7 @@ pub(crate) fn build(
..default()
}),
)
.insert_resource(bevy::winit::WinitSettings {focused_mode:bevy::winit::UpdateMode::Continuous,unfocused_mode:bevy::winit::UpdateMode::Continuous})
.insert_resource(config)
.insert_resource(crate::retail_render::RetailScene(retail_scene))
.insert_resource(assets::AssetManifest(manifest))
@@ -107,5 +108,6 @@ pub(crate) fn build(
crate::performance::PerformancePlugin,
));
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
app.add_plugins(crate::multiplayer::MultiplayerPlugin);
app
}
+41
View File
@@ -10,6 +10,8 @@ pub(crate) struct Config {
pub difficulty: crate::difficulty::Difficulty,
pub check_assets: bool,
pub start_paused: bool,
pub multiplayer: crate::multiplayer::Options,
pub map_fingerprint: u64,
}
impl Config {
@@ -22,12 +24,37 @@ impl Config {
difficulty: crate::difficulty::Difficulty::default(),
check_assets: false,
start_paused: false,
multiplayer: crate::multiplayer::Options::default(),
map_fingerprint: 0,
};
let mut difficulty_override = None;
let mut explicit_map = false;
let mut args = std::env::args_os().skip(1);
while let Some(arg) = args.next() {
match arg.to_str() {
Some("--net-host") => config.multiplayer.host = Some(args.next().ok_or("Missing host bind address")?.to_string_lossy().parse().map_err(|_|"Invalid host bind address")?),
Some("--net-local") => {
let bind=args.next().ok_or("--net-local requires bind and peer addresses")?.to_string_lossy().parse().map_err(|_|"Invalid bind address")?;
let peer=args.next().ok_or("--net-local requires peer address")?.to_string_lossy().parse().map_err(|_|"Invalid peer address")?;
config.multiplayer.direct=Some((bind,peer));
}
Some("--net-session") => config.multiplayer.session=args.next().ok_or("Missing session")?.to_string_lossy().parse().map_err(|_|"Invalid session")?,
Some("--spawn-offset") => {
let offset:f32=args.next().ok_or("Missing spawn offset")?.to_string_lossy().parse().map_err(|_|"Invalid spawn offset")?;
if !offset.is_finite() || offset.abs()>20. {return Err("Spawn offset must be within 20 metres".into());}
config.multiplayer.spawn_offset=offset;
}
Some("--player-title") => config.multiplayer.title=Some(args.next().ok_or("Missing title")?.to_string_lossy().into()),
Some("--appearance") => {
let value=args.next().ok_or("Missing appearance")?.to_string_lossy().into_owned();
if value.len()>128 {return Err("Appearance ID too long".into());}
config.multiplayer.appearance=Some(value);
}
Some("--controller") => {
let slot:u32=args.next().ok_or("Missing controller index")?.to_string_lossy().parse().map_err(|_|"Invalid controller index")?;
if slot>3 {return Err("Controller index must be 0 to 3".into());}
config.multiplayer.controller=Some(slot);
}
Some("--assets") => {
config.asset_root = args.next().ok_or("--assets requires a directory")?.into()
}
@@ -72,6 +99,8 @@ impl Config {
config.map_path = Some(path.canonicalize().map_err(|e| e.to_string())?);
}
}
config.map_fingerprint = map_fingerprint(config.map_path.as_deref())?;
if config.multiplayer.direct.is_some() && config.multiplayer.session==0 {return Err("Direct multiplayer requires --net-session (a nonzero number shared by both players)".into());}
if let Some(path) = &mut config.verification_capture {
if path.extension().and_then(|x| x.to_str()) != Some("png") {
return Err("--verify output must be a PNG file".into());
@@ -88,3 +117,15 @@ impl Config {
Ok(config)
}
}
/// Hash map bytes identically for startup and background map replacement.
pub(crate) fn map_fingerprint(path: Option<&std::path::Path>) -> Result<u64, String> {
Ok(if let Some(path)=path {
use std::io::Read;
let mut file=std::fs::File::open(path).map_err(|e|e.to_string())?;
let mut hash=0xcbf29ce484222325u64;
let mut buffer=[0;65536];
loop {let n=file.read(&mut buffer).map_err(|e|e.to_string())?;if n==0{break;}for b in &buffer[..n]{hash=(hash^u64::from(*b)).wrapping_mul(0x100000001b3);}}
hash
} else {skate_net::hash(b"skate-test-world-v1")})
}
+257 -81
View File
@@ -1,4 +1,5 @@
//! Native-resolution pause UI over a separately scaled 3D render target.
use crate::difficulty::Difficulty;
use bevy::{
camera::RenderTarget,
core_pipeline::prepass::DepthPrepass,
@@ -12,7 +13,6 @@ use bevy::{
window::{PresentMode, PrimaryWindow},
};
use serde::{Deserialize, Serialize};
use crate::difficulty::Difficulty;
use std::{
path::PathBuf,
time::{Duration, Instant},
@@ -84,6 +84,8 @@ pub(crate) struct Menu {
status: String,
maps: Vec<crate::map_library::Entry>,
selected_map: usize,
multiplayer: bool,
browser: bool,
}
impl Menu {
pub(crate) fn transition_finished(&mut self, status: String, resume: bool) {
@@ -207,9 +209,9 @@ fn setup(
}, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.88)))).with_children(|root| {
root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() },
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
for i in 0..11 {
for i in 0..12 {
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
@@ -233,6 +235,8 @@ fn setup(
status: String::new(),
maps,
selected_map,
multiplayer: false,
browser: false,
});
}
fn msaa(samples: u32) -> Msaa {
@@ -258,6 +262,8 @@ pub(crate) fn interact(
mut time: ResMut<Time<Virtual>>,
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
mut exit: MessageWriter<AppExit>,
mut net: ResMut<crate::multiplayer::Multiplayer>,
mut typing: MessageReader<bevy::input::keyboard::KeyboardInput>,
) {
if transition.busy() {
menu.open = true;
@@ -269,12 +275,33 @@ pub(crate) fn interact(
menu.open = !menu.open;
}
let mut action = None;
for event in typing.read() {
if !menu.open
|| !menu.multiplayer
|| menu.browser
|| menu.selected != 3
|| !event.state.is_pressed()
{
continue;
}
if event.key_code == KeyCode::Backspace {
net.join_code.pop();
}
if let Some(text) = &event.text {
for ch in text.chars().filter(|c| c.is_ascii_hexdigit() || *c == '-') {
if net.join_code.len() < 40 {
net.join_code.push(ch);
}
}
}
}
if menu.open {
let rows = if menu.multiplayer { 11 } else { 12 };
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + 10) % 11;
menu.selected = (menu.selected + rows - 1) % rows;
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
menu.selected = (menu.selected + 1) % 11;
menu.selected = (menu.selected + 1) % rows;
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some((menu.selected, -1));
@@ -290,48 +317,107 @@ pub(crate) fn interact(
}
}
if let Some((row, direction)) = action {
match row {
0 => {
let size = cycle(
RESOLUTIONS,
(menu.settings.width, menu.settings.height),
direction,
);
(menu.settings.width, menu.settings.height) = size;
if menu.browser {
match row {
0 => net.browse(0),
1..=5 => net.join_row(row - 1),
6 => {
let page = net.browser_page.saturating_sub(1);
net.browse(page);
}
7 => {
let page = net.browser_page + 1;
if page * 5 < net.browser_total {
net.browse(page);
}
}
8 => menu.open = false,
9 => {
exit.write(AppExit::Success);
}
10 => {
menu.browser = false;
menu.selected = 6;
}
_ => {}
}
1 => menu.settings.scale = cycle(SCALES, menu.settings.scale, direction),
2 => {
menu.settings.samples =
cycle(&menu.supported_msaa, menu.settings.samples, direction)
} else if menu.multiplayer {
match row {
0 => net.local(true),
1 => net.local(false),
2 => net.steam(true),
3 => {}
4 => net.steam(false),
5 => net.leave(),
6 => {
net.browse(0);
menu.browser = true;
menu.selected = 0;
}
8 => menu.open = false,
9 => {
exit.write(AppExit::Success);
}
10 => {
menu.multiplayer = false;
menu.selected = 11;
}
_ => {}
}
3 => menu.settings.fps = cycle(LIMITS, menu.settings.fps, direction),
4 => menu.settings.occlusion = !menu.settings.occlusion,
5 => {
menu.difficulty = cycle(&Difficulty::ALL, menu.difficulty, direction);
physics.set_difficulty(menu.difficulty);
config.difficulty = menu.difficulty;
menu.status = match menu.difficulty.save(&config.asset_root) {
Ok(()) => "Difficulty saved".into(),
Err(e) => format!("Applied, but could not save: {e}"),
};
} else {
match row {
0 => {
let size = cycle(
RESOLUTIONS,
(menu.settings.width, menu.settings.height),
direction,
);
(menu.settings.width, menu.settings.height) = size;
}
1 => menu.settings.scale = cycle(SCALES, menu.settings.scale, direction),
2 => {
menu.settings.samples =
cycle(&menu.supported_msaa, menu.settings.samples, direction)
}
3 => menu.settings.fps = cycle(LIMITS, menu.settings.fps, direction),
4 => menu.settings.occlusion = !menu.settings.occlusion,
5 => {
menu.difficulty = cycle(&Difficulty::ALL, menu.difficulty, direction);
physics.set_difficulty(menu.difficulty);
config.difficulty = menu.difficulty;
menu.status = match menu.difficulty.save(&config.asset_root) {
Ok(()) => "Difficulty saved".into(),
Err(e) => format!("Applied, but could not save: {e}"),
};
}
6 => {
menu.selected_map = (menu.selected_map as i32 + direction)
.rem_euclid(menu.maps.len() as i32)
as usize;
menu.status = "Choose Load map to switch".into();
}
7 => {
if net.active() {
menu.status = "Leave multiplayer before switching maps".into();
} else {
net.leave();
transition.request(menu.maps[menu.selected_map].clone());
menu.status = "Loading map...".into();
}
}
8 => menu.open = false,
9 => {
exit.write(AppExit::Success);
}
10 => customiser.begin(),
11 => {
menu.multiplayer = true;
menu.selected = 0;
}
_ => {}
}
6 => {
menu.selected_map = (menu.selected_map as i32 + direction)
.rem_euclid(menu.maps.len() as i32) as usize;
menu.status = "Choose Load map to switch".into();
}
7 => {
transition.request(menu.maps[menu.selected_map].clone());
menu.status = "Loading map...".into();
}
8 => menu.open = false,
9 => {
exit.write(AppExit::Success);
}
10 => customiser.begin(),
_ => {}
}
if row < 5 {
if row < 5 && !menu.multiplayer {
let save = (|| -> Result<(), String> {
std::fs::create_dir_all(menu.path.parent().unwrap()).map_err(|e| e.to_string())?;
std::fs::write(
@@ -346,7 +432,7 @@ pub(crate) fn interact(
};
}
}
if menu.open {
if menu.open && !net.active() {
time.pause();
} else {
time.unpause();
@@ -377,12 +463,19 @@ fn apply(
*samples = msaa(menu.settings.samples);
}
}
if previous.as_ref().is_none_or(|p| p.occlusion != menu.settings.occlusion) {
if previous
.as_ref()
.is_none_or(|p| p.occlusion != menu.settings.occlusion)
{
for (entity, _) in &cameras {
if menu.settings.occlusion {
commands.entity(entity).insert((DepthPrepass, OcclusionCulling));
commands
.entity(entity)
.insert((DepthPrepass, OcclusionCulling));
} else {
commands.entity(entity).remove::<(DepthPrepass, OcclusionCulling)>();
commands
.entity(entity)
.remove::<(DepthPrepass, OcclusionCulling)>();
}
}
info!("GPU occlusion culling: {}", menu.settings.occlusion);
@@ -404,11 +497,12 @@ fn labels(
transition: Res<crate::map_transition::MapTransition>,
time: Res<Time<Real>>,
customiser: Res<crate::customiser::Customiser>,
net: Res<crate::multiplayer::Multiplayer>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without<MenuRoot>>,
) {
root.display = if menu.open && !customiser.open {
Display::Flex
@@ -421,41 +515,110 @@ fn labels(
let s = &menu.settings;
let size = s.internal_size(window.physical_size());
for (label, mut text) in &mut labels {
**text = match label.0 {
0 => format!("Resolution {} x {}", s.width, s.height),
1 => format!(
"Internal resolution {}% ({} x {})",
s.scale, size.x, size.y
),
2 => format!(
"MSAA {}",
if s.samples == 1 {
"Off".into()
} else {
format!("{}x", s.samples)
}
),
3 => format!(
"FPS limit {}",
if s.fps == 0 {
"Unlimited".into()
} else {
s.fps.to_string()
}
),
4 => format!("Occlusion culling {}", if s.occlusion { "On" } else { "Off" }),
5 => format!("Difficulty {}", menu.difficulty.label()),
6 => format!("Map {}", menu.maps[menu.selected_map].label),
7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() },
8 => "Resume".into(),
9 => "Quit game".into(),
_ => "Character customiser".into(),
**text = if menu.browser {
match label.0 {
0 => "Refresh public Steam lobbies".into(),
1..=5 => net
.browser_rows
.get(label.0 - 1)
.map(|r| {
format!(
"{} | {}/{} | #{}{}",
r.map,
r.players,
r.capacity,
r.id % 100000,
if r.compatible {
""
} else {
" | different map/build"
}
)
})
.unwrap_or_else(|| "--".into()),
6 => "Previous page".into(),
7 => "Next page".into(),
8 => "Resume".into(),
9 => "Quit game".into(),
_ => "Back to multiplayer".into(),
}
} else if menu.multiplayer {
match label.0 {
0 => "Host local test (no Steam)".into(),
1 => "Join local test (no Steam)".into(),
2 => "Host via Steam / Spacewar".into(),
3 => format!(
"Join code: {}{}",
net.join_code,
if menu.selected == 3 { "_" } else { "" }
),
4 => "Join via Steam / Spacewar".into(),
5 => "Leave multiplayer".into(),
6 => "Browse public Steam lobbies".into(),
7 => "Solo / local play does not require Steam".into(),
8 => "Resume".into(),
9 => "Quit game".into(),
_ => "Back to gameplay & graphics".into(),
}
} else {
match label.0 {
0 => format!("Resolution {} x {}", s.width, s.height),
1 => format!(
"Internal resolution {}% ({} x {})",
s.scale, size.x, size.y
),
2 => format!(
"MSAA {}",
if s.samples == 1 {
"Off".into()
} else {
format!("{}x", s.samples)
}
),
3 => format!(
"FPS limit {}",
if s.fps == 0 {
"Unlimited".into()
} else {
s.fps.to_string()
}
),
4 => format!(
"Occlusion culling {}",
if s.occlusion { "On" } else { "Off" }
),
5 => format!("Difficulty {}", menu.difficulty.label()),
6 => format!(
"Map {}",
menu.maps[menu.selected_map].label
),
7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() },
8 => "Resume".into(),
9 => "Quit game".into(),
10 => "Character customiser".into(),
_ => "Multiplayer".into(),
}
};
}
***status = if transition.busy() {
format!("{} {}\nGameplay is paused. Please wait.", ["|", "/", "-", "\\"][(time.elapsed_secs() * 4.) as usize % 4], transition.label())
} else { menu.status.clone() };
for (row, interaction, mut color) in &mut buttons {
} else if menu.browser {
net.browser_status.clone()
} else if menu.multiplayer {
format!(
"{}{}",
net.status,
if net.host_code.is_empty() {
String::new()
} else {
format!("\nYour connection code: {}", net.host_code)
}
)
} else {
menu.status.clone()
};
for (row, interaction, mut color, mut node) in &mut buttons {
node.display = if menu.multiplayer && row.0 == 11 { Display::None } else { Display::Flex };
color.0 = if row.0 == menu.selected || *interaction == Interaction::Hovered {
Color::srgb(0.10, 0.30, 0.34)
} else {
@@ -483,12 +646,18 @@ mod tests {
fn culling_can_toggle_with_msaa_and_render_scale_changes() {
let mut app = App::new();
let mut images = Assets::<Image>::default();
let target = images.add(Image::new_target_texture(1280, 800, TextureFormat::Rgba8UnormSrgb, None));
let target = images.add(Image::new_target_texture(
1280,
800,
TextureFormat::Rgba8UnormSrgb,
None,
));
app.insert_resource(SceneTarget(target.clone()))
.insert_resource(images)
.insert_resource(Menu {
open: false, selected: 0, settings: GraphicsSettings::default(),
difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(),
multiplayer: false, browser: false,
maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0,
})
.add_systems(Update, apply);
@@ -507,7 +676,14 @@ mod tests {
assert!(!app.world().entity(camera).contains::<OcclusionCulling>());
assert!(!app.world().entity(camera).contains::<DepthPrepass>());
assert_eq!(*app.world().get::<Msaa>(camera).unwrap(), Msaa::Off);
assert_eq!(app.world().resource::<Assets<Image>>().get(&target).unwrap().size(), UVec2::new(857, 536));
assert_eq!(
app.world()
.resource::<Assets<Image>>()
.get(&target)
.unwrap()
.size(),
UVec2::new(857, 536)
);
{
let mut menu = app.world_mut().resource_mut::<Menu>();
menu.settings.occlusion = true;
+10 -2
View File
@@ -34,9 +34,15 @@ impl Plugin for InputPlugin {
}
}
pub(crate) fn poll_controllers(mut input: ResMut<ControllerInput>) {
pub(crate) fn poll_controllers(mut input: ResMut<ControllerInput>,config:Res<crate::config::Config>,net:Option<Res<crate::multiplayer::Multiplayer>>,windows:Query<&Window>) {
let previous = input.status;
input.collect(std::array::from_fn(platform::poll));
let focused=windows.iter().any(|w|w.focused);
let active=net.is_some_and(|n|n.active());
input.collect(std::array::from_fn(|slot| {
if active && ((!focused && config.multiplayer.controller.is_none()) || config.multiplayer.controller.is_some_and(|selected|selected as usize!=slot)) {
Err(platform::DeviceError::Disconnected)
} else {platform::poll(slot)}
}));
for (index, (&before, &after)) in previous.iter().zip(&input.status).enumerate() {
if before != after {
match after {
@@ -53,7 +59,9 @@ pub(crate) fn poll_controllers(mut input: ResMut<ControllerInput>) {
fn publish_actions(
mut input: ResMut<ControllerInput>,
mut published: ResMut<PublishedTickInput>,
menu:Option<Res<crate::graphics_menu::Menu>>,
) {
if !crate::graphics_menu::gameplay_active(menu) {input.discard_gameplay();}
input.publish_actions();
published.0 = input.tick_input();
}
+7 -1
View File
@@ -1,4 +1,5 @@
mod animation;
mod multiplayer;
mod animation_pose;
mod app;
mod assets;
@@ -62,13 +63,18 @@ fn main() -> bevy::app::AppExit {
}
}
eprintln!("SKATE_DIFFICULTY mode={} native_index={}", config.difficulty.key(), config.difficulty as u32);
let physics = match physics::GamePhysics::load_with_difficulty(&config.asset_root, config.map.as_ref(), config.difficulty) {
let mut physics = match physics::GamePhysics::load_with_difficulty(&config.asset_root, config.map.as_ref(), config.difficulty) {
Ok(physics) => physics,
Err(error) => {
eprintln!("{error}");
return bevy::app::AppExit::error();
}
};
if config.multiplayer.spawn_offset != 0.0 {
let mut spawn = physics.board.part_transforms()[skate_core::physics::board::BodyId::Deck.index()];
spawn.translation.x += config.multiplayer.spawn_offset;
physics.board.set_transform(spawn);
}
let skater = match physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, config.difficulty.key()) {
Ok(skater) => skater,
Err(error) => {
+4 -1
View File
@@ -33,6 +33,7 @@ impl CurrentMap {
}
struct PreparedWorld {
map_fingerprint: u64,
scene: PreparedScene,
physics: GamePhysics,
skater: SkaterRuntime,
@@ -103,6 +104,7 @@ fn start(world: &World, entry: Entry) -> Result<Phase, String> {
let job = std::thread::Builder::new().name("map-loader".into()).spawn(move || {
let started = Instant::now();
let map = selected.path.as_deref().map(skate_data::skate_map::SkateMap::load).transpose()?;
let map_fingerprint = crate::config::map_fingerprint(selected.path.as_deref())?;
let read_time = started.elapsed();
let validation_started = Instant::now();
if let Some(map) = &map { crate::skate_world::validate_runtime(map)?; }
@@ -145,7 +147,7 @@ fn start(world: &World, entry: Entry) -> Result<Phase, String> {
simulation_time.as_millis(), render_time.as_millis(), started.elapsed().as_millis());
// Drop the decoded package on this worker. Physics and rendering now
// own their data; retaining it would double large-city CPU memory.
Ok(PreparedWorld { scene, physics, skater, controls, camera, metadata, retail, difficulty })
Ok(PreparedWorld { map_fingerprint, scene, physics, skater, controls, camera, metadata, retail, difficulty })
}).map_err(|e| format!("Could not start map loader: {e}"))?;
Ok(Phase::Loading { entry, progress, job })
}
@@ -220,6 +222,7 @@ fn commit(world: &mut World, mut prepared: PreparedWorld) -> String {
world.resource_mut::<ButtonInput<MouseButton>>().reset_all();
let mut config = world.resource_mut::<Config>();
config.map_path = prepared.metadata.path.clone();
config.map_fingerprint = prepared.map_fingerprint;
config.difficulty = prepared.difficulty;
let notice = format!("Loaded {}.", prepared.metadata.name);
info!("MAP_TRANSITION_COMMITTED generation={} name={:?} spawn={:?} heading={} pid={}",
+654
View File
@@ -0,0 +1,654 @@
//! Transport-neutral ten-player free-skate; each player owns their simulation.
mod render;
mod transport;
use crate::{
app::{FrameSet, SimulationSet},
physics::{GamePhysics, SkaterRuntime, network},
};
use bevy::prelude::*;
use skate_net::{
directory::{Command as LobbyCommand, Event, Row},
lobby::{Info, Session},
packed::{self, BodyState, Packed},
};
use std::{
collections::{BTreeMap, VecDeque},
net::SocketAddr,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
pub(crate) fn unique() -> u64 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
skate_net::hash(
&[
nanos.to_le_bytes().as_slice(),
std::process::id().to_le_bytes().as_slice(),
]
.concat(),
)
}
#[derive(Default)]
pub(crate) struct Options {
pub direct: Option<(SocketAddr, SocketAddr)>,
pub host: Option<SocketAddr>,
pub session: u64,
pub spawn_offset: f32,
pub appearance: Option<String>,
pub title: Option<String>,
pub controller: Option<u32>,
}
struct VisualRoot {
captured: f64,
received: f64,
pose: skate_net::Pose,
}
struct VisualPose {
captured: f64,
received: f64,
bones: Vec<skate_net::Bone>,
}
struct Remote {
roots: VecDeque<VisualRoot>,
poses: VecDeque<VisualPose>,
epoch: u64,
visual_since: u64,
body: BodyState,
body_at: Instant,
body_seq: u32,
pose_seq: u32,
}
#[derive(Resource)]
pub(crate) struct Multiplayer {
transport: Option<Box<dyn transport::Transport>>,
lobby: Option<Session>,
info: Info,
schema: network::Schema,
anchors: Vec<usize>,
remotes: BTreeMap<u64, Remote>,
pub status: String,
pub join_code: String,
pub host_code: String,
last_body: Instant,
last_pose: Instant,
started: Instant,
last_metrics: Instant,
counts: (u64, u64),
rates: String,
provider_metrics: String,
visual_status: String,
loopback: bool,
room: Option<(u64, u64)>,
map_name: String,
pub browser_rows: Vec<Row>,
pub browser_page: usize,
pub browser_total: usize,
pub browser_status: String,
}
impl Multiplayer {
pub fn active(&self) -> bool {
self.lobby.is_some()
}
pub fn leave(&mut self) {
if let (Some(lobby), Some(t)) = (&self.lobby, &mut self.transport) {
for p in lobby.goodbye() {
let _ = t.send(p.peer, &p.data);
}
}
self.transport = None;
self.lobby = None;
self.remotes.clear();
self.host_code.clear();
self.room = None;
self.status = "Offline. Steam is only needed for Steam multiplayer.".into();
}
fn start(&mut self, transport: Box<dyn transport::Transport>, session: u64, host: Option<u64>) {
self.leave();
self.info.id = unique();
self.loopback = transport.loopback();
let mut lobby = Session::new(session, self.info, host);
lobby.set_loopback(self.loopback);
self.lobby = Some(lobby);
self.transport = Some(transport);
self.started = Instant::now();
self.last_metrics = Instant::now();
self.counts = (0, 0);
self.rates.clear();
self.provider_metrics.clear();
self.visual_status.clear();
self.status = "Waiting for players... (up to 10)".into();
}
pub fn local(&mut self, host: bool) {
let bind = if host {
"127.0.0.1:31030"
} else {
"127.0.0.1:0"
};
match transport::Direct::new(bind.parse().unwrap()) {
Ok(t) => self.start(
Box::new(t),
480_31030,
if host {
None
} else {
Some(transport::endpoint("127.0.0.1:31030".parse().unwrap()).unwrap())
},
),
Err(e) => self.status = format!("Could not open local session: {e}"),
}
}
fn lobby_command(&mut self, command: LobbyCommand) {
// Retry discovery after Steam was opened following an initialization failure.
if !self.active()
&& self.transport.as_ref().is_some_and(|t| {
let status = t.status();
status.starts_with("ERROR")
|| status.contains("stopped")
|| status.contains("did not respond")
})
{
self.transport = None;
}
if self.transport.is_none() {
match transport::Steam::new(0, 0) {
Ok(t) => self.transport = Some(Box::new(t)),
Err(e) => {
self.status = e.clone();
self.browser_status = e;
return;
}
}
}
let result = self.transport.as_mut().unwrap().command(command);
self.browser_status = match result {
Ok(()) => "Contacting Steam...".into(),
Err(e) => e,
};
if !self.active() {
self.status = self.browser_status.clone();
}
}
pub fn browse(&mut self, page: usize) {
self.lobby_command(LobbyCommand::Browse {
page,
map: self.info.map,
physics: self.info.physics,
});
}
pub fn join_row(&mut self, index: usize) {
let Some(row) = self.browser_rows.get(index) else {
return;
};
if !row.compatible {
self.browser_status =
"Different map/build. Load the matching map before joining.".into();
return;
}
if row.players >= row.capacity {
self.browser_status = "Lobby is full. Refresh to check for a free slot.".into();
return;
}
let lobby = row.id;
self.leave();
self.lobby_command(LobbyCommand::Join {
lobby,
map: self.info.map,
physics: self.info.physics,
});
}
pub fn steam(&mut self, host: bool) {
if host {
self.leave();
self.lobby_command(LobbyCommand::Host {
map: self.info.map,
physics: self.info.physics,
label: self.map_name.clone(),
});
return;
}
if let Ok(lobby) = self.join_code.trim().parse::<u64>() {
if lobby != 0 {
self.leave();
self.lobby_command(LobbyCommand::Join {
lobby,
map: self.info.map,
physics: self.info.physics,
});
return;
}
}
let (peer, session) = {
let Some((id, key)) = self.join_code.trim().split_once('-') else {
self.status = "Enter a lobby code, or select a lobby in the browser.".into();
return;
};
match (id.parse::<u64>(), u64::from_str_radix(key, 16)) {
(Ok(id), Ok(key)) if id != 0 && key != 0 => (id, key),
_ => {
self.status = "Invalid join code. Expected SteamID-session.".into();
return;
}
}
};
match transport::Steam::new(peer, session) {
Ok(t) => self.start(Box::new(t), session, (peer != 0).then_some(peer)),
Err(e) => self.status = e,
}
}
}
pub(crate) struct MultiplayerPlugin;
impl Plugin for MultiplayerPlugin {
fn build(&self, app: &mut App) {
let config = app.world().resource::<crate::config::Config>();
let skater = app.world().resource::<SkaterRuntime>();
let physics = app.world().resource::<GamePhysics>();
let rig = skate_net::hash(
format!(
"{:?}{:?}",
skater.animation.evaluator.frames.bone_names,
skater.animation.evaluator.frames.parents
)
.as_bytes(),
);
let schema =
network::Schema::new(physics, skater).expect("Default multiplayer collision schema");
let mut net = Multiplayer {
transport: None,
lobby: None,
info: Info {
id: unique(),
map: config.map_fingerprint,
rig,
physics: schema.fingerprint,
appearance: skate_net::hash(
config
.multiplayer
.appearance
.as_deref()
.unwrap_or(skate_net::DEFAULT_APPEARANCE)
.as_bytes(),
),
},
schema,
anchors: network::anchors(skater),
remotes: BTreeMap::new(),
status: "Offline. Steam is only needed for Steam multiplayer.".into(),
join_code: String::new(),
host_code: String::new(),
last_body: Instant::now(),
last_pose: Instant::now(),
started: Instant::now(),
last_metrics: Instant::now(),
counts: (0, 0),
rates: String::new(),
provider_metrics: String::new(),
visual_status: String::new(),
loopback: false,
room: None,
map_name: config
.map_path
.as_ref()
.and_then(|p| p.file_stem())
.map(|n| skate_net::directory::label(&n.to_string_lossy()))
.unwrap_or_else(|| "Test world".into()),
browser_rows: vec![],
browser_page: 0,
browser_total: 0,
browser_status: String::new(),
};
if let Some(bind) = config
.multiplayer
.host
.or(config.multiplayer.direct.map(|(b, _)| b))
{
let target = config
.multiplayer
.direct
.map(|(_, p)| transport::endpoint(p).expect("IPv4 peer"));
match transport::Direct::new(bind) {
Ok(t) => net.start(Box::new(t), config.multiplayer.session, target),
Err(e) => net.status = format!("Local multiplayer could not start: {e}"),
}
}
app.insert_resource(net)
.add_systems(PreUpdate, (world_changed, receive).chain().after(crate::map_transition::MapTransitionSet))
.add_systems(Startup, setup_hud)
.add_systems(Update, hud)
.add_systems(Update, send_pose.after(FrameSet::Animation))
.add_systems(
FixedUpdate,
prepare
.before(SimulationSet::Physics)
.after(SimulationSet::Controls),
)
.add_systems(FixedUpdate, send.after(SimulationSet::Physics))
.add_plugins(render::RemoteRenderPlugin);
}
}
// A world swap replaces the local physics assembly. Never retain lobby identity
// or remote collision state from the previous map, including pending Steam joins.
fn world_changed(
mut changed: MessageReader<crate::map_transition::WorldChanged>,
config: Res<crate::config::Config>,
physics: Res<GamePhysics>,
skater: Res<SkaterRuntime>,
mut net: ResMut<Multiplayer>,
) {
if changed.read().count() == 0 { return; }
net.leave();
net.info.map = config.map_fingerprint;
net.map_name = config.map_path.as_ref().and_then(|p| p.file_stem())
.map(|n| skate_net::directory::label(&n.to_string_lossy()))
.unwrap_or_else(|| "Test world".into());
if let Ok(schema) = network::Schema::new(&physics, &skater) {
net.info.physics = schema.fingerprint;
net.schema = schema;
}
net.anchors = network::anchors(&skater);
net.browser_rows.clear();
net.browser_page = 0;
net.browser_total = 0;
net.browser_status.clear();
}
fn receive(mut net: ResMut<Multiplayer>) {
let now = net.started.elapsed().as_millis() as u64;
let net = &mut *net;
let Some(t) = &mut net.transport else {
return;
};
let packets = match t.receive() {
Ok(p) => p,
Err(e) => {
net.status = format!("Connection error: {e}");
return;
}
};
let provider = t.status();
for response in t.events() {
match response.event {
Event::Rows { page, total, rows } => {
net.browser_rows = rows;
net.browser_page = page;
net.browser_total = total;
net.browser_status = if total == 0 {
"No public lobbies found. Host one or refresh.".into()
} else {
format!(
"{} lobbies | Page {} | select a row to join",
total,
page + 1
)
};
}
Event::Error(e) => {
net.browser_status = e.clone();
if net.lobby.is_none() {
net.status = e;
}
}
Event::Owner {
lobby: room,
owner,
own,
} => {
if net.room != Some((room, owner)) {
let host = (owner != own).then_some(owner);
if net.room.is_some_and(|(id, _)| id == room) {
if let Some(session) = &mut net.lobby {
session.migrate(host, now);
}
info!(
"MULTIPLAYER_HOST_MIGRATED lobby={room} owner={owner} local_host={}",
host.is_none()
);
} else {
net.info.id = unique();
net.lobby = Some(Session::new(room, net.info, host));
net.counts = (0, 0);
net.last_metrics = Instant::now();
}
net.remotes.clear();
net.loopback = false;
net.room = Some((room, owner));
net.host_code = room.to_string();
net.browser_status = if host.is_none() {
"Hosting public lobby. Other players can join from the browser.".into()
} else {
"Lobby joined; connecting to host...".into()
};
}
}
}
}
let Some(lobby) = &mut net.lobby else {
if provider.starts_with("ERROR")
|| provider.contains("stopped")
|| provider.contains("did not respond")
{
net.browser_status = provider.clone();
net.status = provider;
}
return;
};
if net.room.is_none() && lobby.is_host() {
if let Some(id) = provider.strip_prefix("READY ") {
net.host_code = format!("{}-{:016x}", id, lobby.session);
}
}
lobby.set_congested(t.congested());
net.provider_metrics = t.metrics();
for (peer, p) in packets {
lobby.receive(peer, &p, now);
}
for p in lobby.service(now) {
let success = t.send(p.peer, &p.data).is_ok();
lobby.record_send(p.data.len(), success);
}
net.remotes.retain(|id, _| lobby.actors.contains_key(id));
for (&id, actor) in &lobby.actors {
if id == lobby.local {
continue;
}
let Some(body) = actor.body.latest() else {
continue;
};
if now.saturating_sub(body.received) > 3500 {
net.remotes.remove(&id);
continue;
}
if let std::collections::btree_map::Entry::Vacant(entry) = net.remotes.entry(id) {
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());
info!("MULTIPLAYER_CONNECTED peer={id} fallback={fallback}");
entry.insert(Remote {
roots: VecDeque::new(),
poses: VecDeque::new(),
epoch: 0,
visual_since: 0,
body: initial,
body_at: Instant::now(),
body_seq: 0,
pose_seq: 0,
});
}
let remote = net.remotes.get_mut(&id).unwrap();
// Consume every newly decoded source sample, including several delivered in one frame.
for revision in &actor.body.history {
if revision.seq <= remote.body_seq {
continue;
}
let Some(state) = revision.state.unpack_body() else {
continue;
};
if remote.roots.back().is_some_and(|p| {
Vec3::from_array(p.pose.p).distance(Vec3::from_array(state.root.p)) > 10.
}) {
remote.roots.clear();
remote.poses.clear();
remote.epoch += 1;
remote.visual_since = revision.state.captured;
}
remote.roots.push_back(VisualRoot {
captured: revision.state.captured as f64 / 1000.,
received: revision.received as f64 / 1000.,
pose: state.root,
});
while remote.roots.len() > 64 {
remote.roots.pop_front();
}
remote.body = state;
remote.body_at =
Instant::now() - Duration::from_millis(now.saturating_sub(revision.received));
remote.body_seq = revision.seq;
}
for revision in &actor.pose.history {
if revision.seq <= remote.pose_seq {
continue;
}
remote.pose_seq = revision.seq;
if revision.state.captured < remote.visual_since {
continue;
}
let Some(mut pose) = revision.state.unpack_pose() else {
continue;
};
if actor.info.rig != net.info.rig
|| pose
.bones
.iter()
.map(|b| b.index as usize)
.ne(net.anchors.iter().copied())
{
pose.bones.clear();
}
remote.poses.push_back(VisualPose {
captured: revision.state.captured as f64 / 1000.,
received: revision.received as f64 / 1000.,
bones: pose.bones,
});
while remote.poses.len() > 64 {
remote.poses.pop_front();
}
}
}
net.status = if !lobby.notice.is_empty() {
lobby.notice.clone()
} else if lobby.actors.len() > 1 {
format!(
"Connected: {}/10 players | collisions on | default skaters",
lobby.actors.len()
)
} else {
format!("{} | waiting for players (1/10)", provider)
};
if net.last_metrics.elapsed() >= Duration::from_secs(1) {
let dt = net.last_metrics.elapsed().as_secs_f64();
let s = &lobby.stats;
net.rates = format!(
"App up {:.1} / down {:.1} kB/s | RTT {} ms | stale {} | delta misses {} | send errors {}",
(s.tx_bytes - net.counts.0) as f64 / dt / 1000.,
(s.rx_bytes - net.counts.1) as f64 / dt / 1000.,
s.rtt_ms,
s.late,
s.baseline_miss,
s.send_errors
);
info!(
"MULTIPLAYER_STATS players={} {} budget_skips={} {}",
lobby.actors.len(),
net.rates,
s.budget_skips,
net.provider_metrics
);
net.counts = (s.tx_bytes, s.rx_bytes);
net.last_metrics = Instant::now();
}
}
fn prepare(net: Res<Multiplayer>, mut physics: ResMut<GamePhysics>, skater: Res<SkaterRuntime>) {
physics.network_active = net.active();
physics.network_contacts = 0;
let mut proxies = std::mem::take(&mut physics.network_proxies);
proxies.bodies.clear();
proxies.volumes.clear();
for remote in net.remotes.values() {
if remote.body_at.elapsed() < Duration::from_millis(500) {
proxies.append(
&remote.body,
&net.schema,
&physics,
&skater,
remote.body_at.elapsed().as_secs_f32().min(0.05),
);
}
}
physics.network_proxies = proxies;
}
#[derive(Component)]
struct NetworkHud;
fn setup_hud(mut commands: Commands) {
commands.spawn((
NetworkHud,
Text::new(""),
TextFont {
font_size: 15.,
..default()
},
TextColor(Color::WHITE),
GlobalZIndex(5),
Node {
position_type: PositionType::Absolute,
top: px(8),
left: px(8),
padding: UiRect::all(px(6)),
..default()
},
BackgroundColor(Color::srgba(0., 0., 0., 0.6)),
));
}
fn hud(
net: Res<Multiplayer>,
physics: Res<GamePhysics>,
mut label: Single<&mut Text, With<NetworkHud>>,
) {
***label = if net.active() {
format!(
"{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer",
net.status,
net.rates,
net.provider_metrics,
net.visual_status,
physics.network_contacts
)
} else {
String::new()
};
}
fn send(mut net: ResMut<Multiplayer>, physics: Res<GamePhysics>, skater: Res<SkaterRuntime>) {
if !net.active() || skater.pose_generation == 0 {
return;
}
let now = net.started.elapsed().as_millis() as u64;
if net.last_body.elapsed() >= Duration::from_millis(49) {
if let Some(p) = Packed::body(&network::capture_body(&physics, &skater)) {
net.lobby.as_mut().unwrap().publish(packed::BODY, p, now);
}
net.last_body = Instant::now();
}
}
fn send_pose(mut net: ResMut<Multiplayer>, skater: Res<SkaterRuntime>) {
if !net.active() || skater.pose_generation == 0 {
return;
}
let now = net.started.elapsed().as_millis() as u64;
if net.last_pose.elapsed() >= Duration::from_millis(if net.loopback { 49 } else { 99 }) {
if let Some(p) = Packed::pose(&network::capture_pose(&skater, &net.anchors)) {
net.lobby.as_mut().unwrap().publish(packed::POSE, p, now);
}
net.last_pose = Instant::now();
}
}
+294
View File
@@ -0,0 +1,294 @@
use super::*;
use bevy::mesh::skinning::SkinnedMesh;
use skate_net::interpolation::{Buffer, Clock, position};
#[derive(Resource)]
struct RemoteSkins {
reported: f64,
actors: BTreeMap<u64, RemoteSkin>,
rest: Vec<Mat4>,
parents: Vec<i32>,
}
#[derive(Default)]
struct RemoteSkin {
root: Option<Entity>,
bindings: Vec<(Entity, usize, Option<usize>)>,
positions: Buffer<[f32; 3]>,
roots: Buffer<Transform>,
poses: Buffer<Vec<Transform>>,
clock: Clock,
epoch: u64,
}
pub(super) struct RemoteRenderPlugin;
impl Plugin for RemoteRenderPlugin {
fn build(&self, app: &mut App) {
let idle = app
.world()
.resource::<crate::assets::AssetManifest>()
.0
.initial_animation
.clone();
let skater = app.world().resource::<SkaterRuntime>();
let pose = skater
.animation
.evaluator
.evaluate(&[
skate_core::animation::playback_tree::PoseCommand::Clip {
name: idle,
previous_time: 0.,
time: 0.,
loops: 0,
},
skate_core::animation::playback_tree::PoseCommand::Pose {
name: "RIG_TPOSE".into(),
},
skate_core::animation::playback_tree::PoseCommand::Add { motion_is_a: true },
])
.unwrap_or_else(|_| skater.animation.pose.clone());
let rest = pose
.iter()
.copied()
.map(skate_core::animation::output::sqt_to_matrix)
.map(crate::animation::native_matrix)
.collect();
let parents = skater
.animation
.evaluator
.frames
.parents
.iter()
.map(|&p| p as i32)
.collect();
app.insert_resource(RemoteSkins {
reported: 0.,
actors: BTreeMap::new(),
rest,
parents,
})
.add_systems(
Update,
(spawn, bind, present).chain().after(FrameSet::Animation),
);
}
}
fn spawn(
mut commands: Commands,
net: Res<Multiplayer>,
mut skins: ResMut<RemoteSkins>,
server: Res<AssetServer>,
) {
let removed: Vec<_> = skins
.actors
.keys()
.filter(|id| !net.remotes.contains_key(id))
.copied()
.collect();
for id in removed {
if let Some(root) = skins.actors.remove(&id).and_then(|s| s.root) {
commands.entity(root).despawn();
}
}
for (&id, _) in &net.remotes {
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(
commands
.spawn((
Transform::default(),
Visibility::default(),
Name::new("Remote default skater"),
))
.with_children(|root| {
root.spawn(SceneRoot(
server.load(GltfAssetLabel::Scene(0).from_asset("private/skater.glb")),
));
})
.id(),
);
}
}
fn bind(
mut skins: ResMut<RemoteSkins>,
skater: Res<SkaterRuntime>,
meshes: Query<(Entity, &SkinnedMesh)>,
names: Query<&Name>,
parents: Query<&ChildOf>,
) {
for skin in skins.actors.values_mut() {
let Some(root) = skin.root else {
continue;
};
if !skin.bindings.is_empty() {
continue;
}
let bone_names = &skater.animation.evaluator.frames.bone_names;
for (entity, mesh) in &meshes {
if !parents.iter_ancestors(entity).any(|p| p == root) {
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;
}
}
}
fn globals(bones: &[skate_net::Bone], skin: &RemoteSkins) -> Vec<Mat4> {
let mut result = vec![None; skin.rest.len()];
for b in bones {
if let Some(slot) = result.get_mut(b.index as usize) {
*slot = Some(network::matrix(b.pose));
}
}
fn visit(i: usize, skin: &RemoteSkins, result: &mut [Option<Mat4>]) -> Mat4 {
if let Some(m) = result[i] {
return m;
}
let p = skin.parents[i];
let matrix = if p >= 0 {
visit(p as usize, skin, result) * skin.rest[i]
} else {
skin.rest[i]
};
result[i] = Some(matrix);
matrix
}
for i in 0..result.len() {
visit(i, skin, &mut result);
}
result.into_iter().map(Option::unwrap).collect()
}
fn present(
mut net: ResMut<Multiplayer>,
mut skins: ResMut<RemoteSkins>,
mut nodes: Query<&mut Transform>,
) {
let basis = Mat4::from_cols(Vec4::X, -Vec4::Z, Vec4::Y, Vec4::W);
let now = net.started.elapsed().as_secs_f64();
for (&id, remote) in &net.remotes {
let Some(mut skin) = skins.actors.remove(&id) else {
continue;
};
if skin.epoch != remote.epoch {
skin.positions = Buffer::default();
skin.roots = Buffer::default();
skin.poses = Buffer::default();
skin.clock = Clock::for_connection(net.loopback);
skin.epoch = remote.epoch;
}
for sample in &remote.roots {
if skin
.roots
.samples
.back()
.is_some_and(|p| p.time >= sample.captured)
{
continue;
}
if skin.roots.insert(
sample.captured,
Transform::from_matrix(network::matrix(sample.pose)),
) {
skin.positions.insert(sample.captured, sample.pose.p);
skin.clock.observe(0, sample.captured, sample.received);
}
}
// Each animation sample is resolved once; physics arrivals never duplicate it.
if !skin.bindings.is_empty() {
for sample in &remote.poses {
if skin
.poses
.samples
.back()
.is_some_and(|p| p.time >= sample.captured)
{
continue;
}
let g = globals(&sample.bones, &skins);
let locals = skin
.bindings
.iter()
.map(|&(_, i, parent)| {
Transform::from_matrix(
parent
.map_or(g[i] * basis, |p| (g[p] * basis).inverse() * g[i] * basis),
)
})
.collect();
if skin.poses.insert(sample.captured, locals) {
skin.clock.observe(1, sample.captured, sample.received);
}
}
}
if let (Some(root), Some(time)) = (skin.root, skin.clock.step(now)) {
if let Some((a, b, alpha)) = skin.roots.pair(time) {
let mut transform = crate::presentation::blend(
skin.roots.samples[a].value,
skin.roots.samples[b].value,
alpha,
);
if let Some(p) = position(&skin.positions, time) {
transform.translation = Vec3::from_array(p);
}
if let Ok(mut t) = nodes.get_mut(root) {
*t = transform;
}
}
if let Some((a, b, alpha)) = skin.poses.pair(time) {
let ag = &skin.poses.samples[a].value;
let bg = &skin.poses.samples[b].value;
for ((&(entity, _, _), a), b) in skin.bindings.iter().zip(ag).zip(bg) {
if let Ok(mut t) = nodes.get_mut(entity) {
*t = crate::presentation::blend(*a, *b, alpha);
}
}
}
}
skins.actors.insert(id, skin);
}
if now < skins.reported || now - skins.reported >= 1. {
let delay = skins
.actors
.values()
.map(|s| s.clock.delay)
.fold(0., f64::max);
let stalls: u64 = skins.actors.values().map(|s| s.clock.underruns).sum();
net.visual_status = format!(
"Visual interpolation: {:.0} ms target buffer | stalls {}",
delay * 1000.,
stalls
);
if net.active() {
info!("MULTIPLAYER_INTERPOLATION {}", net.visual_status);
}
skins.reported = now;
}
}
@@ -0,0 +1,295 @@
use skate_net::directory::{self, Command as LobbyCommand, Event, Request, Response};
use std::{
io,
net::{SocketAddr, UdpSocket},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
/// Platform adapters move opaque bounded datagrams; session/actors live above this.
pub(super) trait Transport: Send + Sync {
fn send(&mut self, peer: u64, data: &[u8]) -> io::Result<()>;
fn receive(&mut self) -> io::Result<Vec<(u64, Vec<u8>)>>;
fn status(&self) -> String;
fn command(&mut self, _command: LobbyCommand) -> Result<(), String> {
Err("Leave local multiplayer before browsing Steam lobbies".into())
}
fn events(&mut self) -> Vec<Response> {
vec![]
}
fn loopback(&self) -> bool {
false
}
fn metrics(&self) -> String {
String::new()
}
fn congested(&self) -> bool {
false
}
}
pub(super) fn endpoint(addr: SocketAddr) -> io::Result<u64> {
match addr {
SocketAddr::V4(a) => Ok(((u32::from(*a.ip()) as u64) << 16) | a.port() as u64),
_ => Err(io::Error::other("Direct testing currently requires IPv4")),
}
}
pub(super) struct Direct {
socket: UdpSocket,
}
impl Direct {
pub fn new(bind: SocketAddr) -> io::Result<Self> {
endpoint(bind)?;
let socket = UdpSocket::bind(bind)?;
socket.set_nonblocking(true)?;
Ok(Self { socket })
}
}
impl Transport for Direct {
fn loopback(&self) -> bool {
self.socket.local_addr().is_ok_and(|a| a.ip().is_loopback())
}
fn send(&mut self, peer: u64, data: &[u8]) -> io::Result<()> {
let address =
std::net::SocketAddrV4::new(std::net::Ipv4Addr::from((peer >> 16) as u32), peer as u16);
self.socket.send_to(data, address).map(|_| ())
}
fn receive(&mut self) -> io::Result<Vec<(u64, Vec<u8>)>> {
let mut output = Vec::new();
let mut buffer = [0; 1500];
for _ in 0..256 {
match self.socket.recv_from(&mut buffer) {
Ok((n, from)) if n <= skate_net::packed::MTU => {
output.push((endpoint(from)?, buffer[..n].to_vec()))
}
Ok(_) => (),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock
| io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionRefused
) =>
{
break;
}
Err(e) => return Err(e),
}
}
Ok(output)
}
fn status(&self) -> String {
"Direct connection (Steam not required)".into()
}
}
pub(super) struct Steam {
socket: UdpSocket,
child: Child,
peer: Option<SocketAddr>,
cookie: String,
status: String,
last_ping: Instant,
started: Instant,
metrics: String,
congested: bool,
events: Vec<Response>,
pending: Option<(Request, Instant)>,
last_command: Instant,
request_id: u64,
}
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())?;
let dir = std::env::current_exe()
.map_err(|e| e.to_string())?
.parent()
.unwrap()
.to_path_buf();
let helper = dir.join("steam-relay/skate-steam-relay.exe");
if !helper.is_file() || !dir.join("steam-relay/steam_api64.dll").is_file() {
return Err(
"Steam relay files missing; solo and direct multiplayer remain available".into(),
);
}
let cookie = format!("{:016x}", super::unique());
let mut command = Command::new(helper);
command
.args([
socket.local_addr().map_err(|e| e.to_string())?.to_string(),
peer.to_string(),
session.to_string(),
cookie.clone(),
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let child = command
.spawn()
.map_err(|e| format!("Could not start Steam relay: {e}"))?;
Ok(Self {
socket,
child,
peer: None,
cookie,
status: "Checking Steam; open Steam and sign in if needed".into(),
last_ping: Instant::now(),
started: Instant::now(),
metrics: String::new(),
congested: false,
events: vec![],
pending: None,
last_command: Instant::now() - Duration::from_secs(1),
request_id: 0,
})
}
}
impl Drop for Steam {
fn drop(&mut self) {
if let Some(peer) = self.peer {
let _ = self
.socket
.send_to(format!("QUIT {}", self.cookie).as_bytes(), peer);
for _ in 0..10 {
if self.child.try_wait().ok().flatten().is_some() {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Transport for Steam {
fn command(&mut self, command: LobbyCommand) -> Result<(), String> {
if self.pending.is_some() {
return Err("Steam request in progress; please wait".into());
}
self.request_id += 1;
self.pending = Some((
Request {
id: self.request_id,
command,
},
Instant::now(),
));
self.last_command = Instant::now() - Duration::from_secs(1);
Ok(())
}
fn events(&mut self) -> Vec<Response> {
std::mem::take(&mut self.events)
}
fn send(&mut self, target: u64, data: &[u8]) -> io::Result<()> {
let peer = self
.peer
.ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, "Relay starting"))?;
let mut packet = u64::from_str_radix(&self.cookie, 16)
.unwrap()
.to_le_bytes()
.to_vec();
packet.extend(target.to_le_bytes());
packet.extend(data);
self.socket.send_to(&packet, peer).map(|_| ())
}
fn receive(&mut self) -> io::Result<Vec<(u64, Vec<u8>)>> {
let mut packets = vec![];
let mut buffer = [0; 1500];
let prefix = format!("SK8RELAY {} ", self.cookie);
for _ in 0..256 {
match self.socket.recv_from(&mut buffer) {
Ok((n, from)) => {
if !from.ip().is_loopback() || self.peer.is_some_and(|p| p != from) {
continue;
}
if let Ok(text) = std::str::from_utf8(&buffer[..n]) {
if let Some(status) = text.strip_prefix(&prefix) {
self.peer = Some(from);
if let Some(json) = status.strip_prefix("EVENT ") {
if let Some(response) = directory::response(json) {
if response.request == 0
|| self
.pending
.as_ref()
.is_some_and(|(r, _)| r.id == response.request)
{
if response.request != 0 {
self.pending = None;
}
self.events.push(response);
}
}
} else if let Some(stats) = status.strip_prefix("STATS ") {
self.congested = stats.split_whitespace().next() == Some("1");
self.metrics = stats.split_once(' ').map_or("", |(_, s)| s).into();
} else {
self.status = status.into();
}
continue;
}
}
if self.peer == Some(from)
&& n >= 16
&& n <= skate_net::packed::MTU + 16
&& u64::from_le_bytes(buffer[..8].try_into().unwrap())
== u64::from_str_radix(&self.cookie, 16).unwrap()
{
packets.push((
u64::from_le_bytes(buffer[8..16].try_into().unwrap()),
buffer[16..n].to_vec(),
));
}
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(e) => return Err(e),
}
}
if let Some((request, since)) = &self.pending {
if since.elapsed() > Duration::from_secs(18) {
self.events.push(Response {
request: request.id,
event: Event::Error(
"Steam request timed out. Open Steam and sign in, then retry.".into(),
),
});
self.pending = None;
} else if self.last_command.elapsed() > Duration::from_millis(500) {
if let Some(peer) = self.peer {
let _ = self.socket.send_to(
format!("CMD {} {}", self.cookie, directory::encode(request)).as_bytes(),
peer,
);
self.last_command = Instant::now();
}
}
}
if self.last_ping.elapsed() > Duration::from_secs(1) {
if let Some(p) = self.peer {
let _ = self
.socket
.send_to(format!("PING {}", self.cookie).as_bytes(), p);
}
self.last_ping = Instant::now();
}
if self.child.try_wait()?.is_some() && !self.status.starts_with("ERROR") {
self.status = "Steam relay stopped. Open Steam, sign in, and retry.".into();
}
if self.peer.is_none() && self.started.elapsed() > Duration::from_secs(10) {
self.status = "Steam did not respond. Open Steam, sign in, and retry.".into();
}
Ok(packets)
}
fn status(&self) -> String {
self.status.clone()
}
fn metrics(&self) -> String {
self.metrics.clone()
}
fn congested(&self) -> bool {
self.congested
}
}
@@ -0,0 +1,2 @@
// A unique build artifact for this worktree's multiplayer test launcher.
include!("main.rs");
+7
View File
@@ -25,6 +25,7 @@ mod skeleton_feedback;
mod skeleton_input_runtime;
mod skeleton_output;
mod solve;
pub(crate) mod network;
pub(crate) use skater::SkaterRuntime;
mod animation_feedback;
mod animation_feedback_settings;
@@ -70,6 +71,9 @@ use skate_data::collections::Collections;
#[derive(Resource)]
pub(crate) struct GamePhysics {
pub(crate) network_proxies: network::Proxies,
pub(crate) network_active: bool,
pub(crate) network_contacts: usize,
clock: clock::SimulationClock,
pub board: BoardRuntime,
pub riding: RidingOutputs,
@@ -238,6 +242,9 @@ impl GamePhysics {
let riding = RidingOutputs::load(&data, &board, processed_flags_2468)?;
let (query, retention) = ground::query_settings();
Ok(Self {
network_proxies: network::Proxies::default(),
network_active: false,
network_contacts: 0,
clock: clock::SimulationClock::default(),
grind: grind::Runtime::load(&data, map)?,
board,
+2 -1
View File
@@ -21,8 +21,9 @@ pub(super) fn advance(
//SimController8285C968 dispatches the preceding tick's camera messages
//before simulation. Keep End/Begin ordering when both occur in one update.
for request in camera.simulation_rate_requests.drain(..) {
physics.clock.apply(request)?;
if !physics.network_active { physics.clock.apply(request)?; }
}
if physics.network_active { physics.clock = super::clock::SimulationClock::default(); }
if physics.ticks == 0 {
player_state::initialize(physics, skater)?;
//Ctor82DB3008 enters Ground without ProcessOutput. Keep the constructed
+338
View File
@@ -0,0 +1,338 @@
//! Physics/transport boundary. Remote dynamic proxies join the existing solve;
//! only the locally owned assembly retains its solved reactions.
use super::*;
use skate_core::physics::{
assembly::BodySnapshot,
board_step::CollisionBody,
board_world::BoardWorldVolume,
collision::Sphere,
rigid_body::{RetailQuaternion, world_inverse_inertia},
world_contact::{ContactPrimitive, triangle_from_volume},
};
use skate_net::{
Body, Bone, Pose,
packed::{BodyState, PoseState},
};
pub(crate) fn pose(matrix: Mat4) -> Pose {
let t = Transform::from_matrix(matrix);
Pose {
p: t.translation.to_array(),
q: t.rotation.normalize().to_array(),
}
}
pub(crate) fn matrix(p: Pose) -> Mat4 {
Mat4::from_rotation_translation(Quat::from_array(p.q).normalize(), Vec3::from_array(p.p))
}
fn xyz(v: Vector3) -> [f32; 3] {
[v.x, v.y, v.z]
}
fn vector(v: [f32; 3]) -> Vector3 {
Vector3::new(v[0], v[1], v[2])
}
fn add(a: Vector3, b: Vector3) -> Vector3 {
Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z)
}
fn body_pose(body: &BodySnapshot) -> Pose {
let q = body.rates.orientation;
Pose {
p: xyz(body.rates.position),
q: Quat::from_xyzw(q.x, q.y, q.z, q.w).normalize().to_array(),
}
}
pub(crate) fn anchors(skater: &SkaterRuntime) -> Vec<usize> {
let mut indices = skater.animated_skeleton.bone_indices.to_vec();
let b = skater.skeleton_output.pose.board_bones;
indices.extend([
b.front_truck,
b.back_truck,
b.front_left_wheel,
b.front_right_wheel,
b.back_left_wheel,
b.back_right_wheel,
]);
// The physical head body is NECK1; HEAD is a separate visual joint.
// Replicate it explicitly rather than replacing its animated neck offset.
if let Some(head) = skater
.animation
.evaluator
.frames
.bone_names
.iter()
.position(|n| n.eq_ignore_ascii_case("HEAD"))
{
indices.push(head);
}
indices.sort_unstable();
indices.dedup();
indices
}
pub(crate) fn capture_body(physics: &GamePhysics, skater: &SkaterRuntime) -> BodyState {
let mut enabled = 0u64;
if skater.offboard.board_policy.volumes_enabled {
for (i, b) in physics.board.bodies().iter().enumerate() {
if b.state_flags != 1 {
enabled |= 1 << i;
}
}
}
for (i, part) in skater.skeleton_collision.parts.iter().enumerate() {
if part.enabled && part.volume_group == 0 {
enabled |= 1 << (7 + i);
}
}
if skater.skeleton_collision.is_ragdoll {
enabled |= 1 << 63;
}
BodyState {
root: pose(crate::animation::native_matrix(
skater.animated_skeleton.roots.animation_to_world,
)),
enabled,
bodies: physics
.board
.bodies()
.iter()
.chain(skater.skeleton.bodies())
.map(|b| Body {
pose: body_pose(b),
velocity: xyz(b.rates.linear_velocity),
angular: xyz(b.rates.angular_velocity),
})
.collect(),
}
}
pub(crate) fn capture_pose(skater: &SkaterRuntime, anchors: &[usize]) -> PoseState {
PoseState {
root: pose(crate::animation::native_matrix(
skater.animated_skeleton.roots.animation_to_world,
)),
bones: anchors
.iter()
.filter_map(|&index| {
skater.render_pose.get(index).map(|m| Bone {
index: index as u16,
pose: pose(crate::animation::native_matrix(*m)),
})
})
.collect(),
}
}
/// Immutable stock geometry in each rigid body's COM frame; never transmitted.
pub(crate) struct Schema {
volumes: Vec<(usize, BoardWorldVolume)>,
pub fingerprint: u64,
}
impl Schema {
pub fn new(physics: &GamePhysics, skater: &SkaterRuntime) -> Result<Self, String> {
let mut mode = skate_core::physics::skeleton_body::SkeletonCollisionMode::new_normal(
skater.skeleton_collision.settings,
false,
);
for part in &mut mode.parts {
part.enabled = true;
part.volume_group = 0;
}
let mut volumes = colliders::world_volumes(&physics.board, &physics.settings);
volumes.extend(skeleton_colliders::world_volumes(&skater.skeleton, &mode)?);
let bodies = capture_body(physics, skater);
let volumes = volumes
.into_iter()
.map(|mut v| {
let i = match v.body {
CollisionBody::Board(id) => id.index(),
CollisionBody::Attached(i) => i + 7,
_ => unreachable!(),
};
v.primitive = transform(v.primitive, matrix(bodies.bodies[i].pose).inverse());
(i, v)
})
.collect();
let settings = &physics.settings;
let fingerprint = skate_net::hash(
format!(
"v2{:?}{:?}{:?}{:?}{:?}{:?}{:?}",
settings.masses,
settings.authored,
settings.deck_geometry,
settings.truck_shape,
(settings.wheel_radius, settings.truck_collisions),
skater.skeleton.definition.parts,
skater.skeleton.definition.bones
)
.as_bytes(),
);
Ok(Self {
volumes,
fingerprint,
})
}
}
fn transform(shape: ContactPrimitive, m: Mat4) -> ContactPrimitive {
let point = |p| vector(m.transform_point3(Vec3::from_array(xyz(p))).to_array());
let direction = |p| vector(m.transform_vector3(Vec3::from_array(xyz(p))).to_array());
match shape {
ContactPrimitive::Sphere(s) => ContactPrimitive::Sphere(Sphere {
center: point(s.center),
..s
}),
ContactPrimitive::Capsule {
center,
axis,
half_length,
radius,
} => ContactPrimitive::Capsule {
center: point(center),
axis: direction(axis),
half_length,
radius,
},
ContactPrimitive::RoundedBox {
center,
basis,
half_extents,
radius,
} => ContactPrimitive::RoundedBox {
center: point(center),
basis: skate_core::math::Basis3 {
columns: (Mat3::from_mat4(m) * Mat3::from_cols_array_2d(&basis.columns))
.to_cols_array_2d(),
},
half_extents,
radius,
},
ContactPrimitive::Triangle(t) => ContactPrimitive::Triangle(triangle_from_volume(
t.vertices.map(point),
t.fatness,
[-1.; 3],
0,
)),
}
}
/// Conservative enclosing spheres for the remote broad phase.
pub(crate) fn bounds(p: ContactPrimitive) -> (Vec3, f32) {
match p {
ContactPrimitive::Sphere(s) => (Vec3::from_array(xyz(s.center)), s.radius),
ContactPrimitive::Capsule {
center,
half_length,
radius,
..
} => (Vec3::from_array(xyz(center)), half_length + radius),
ContactPrimitive::RoundedBox {
center,
half_extents,
radius,
..
} => (
Vec3::from_array(xyz(center)),
Vec3::from_array(xyz(half_extents)).length() + radius,
),
ContactPrimitive::Triangle(t) => {
let vertices = t.vertices.map(|v| Vec3::from_array(xyz(v)));
let center = (vertices[0] + vertices[1] + vertices[2]) / 3.;
(
center,
vertices
.iter()
.map(|v| v.distance(center))
.fold(0., f32::max)
+ t.fatness,
)
}
}
}
#[derive(Default)]
pub(crate) struct Proxies {
pub bodies: Vec<BodySnapshot>,
pub volumes: Vec<BoardWorldVolume>,
}
impl Proxies {
pub fn append(
&mut self,
frame: &BodyState,
schema: &Schema,
physics: &GamePhysics,
skater: &SkaterRuntime,
age: f32,
) {
// Include detached boards as well as the rider. Distant bodies never enter the solver.
let nearby = frame.bodies.iter().any(|b| {
physics
.board
.bodies()
.iter()
.chain(skater.skeleton.bodies())
.any(|local| {
Vec3::from_array(b.pose.p)
.distance_squared(Vec3::from_array(xyz(local.rates.position)))
< 64.
})
});
if !nearby {
return;
}
let base = skater.skeleton.bodies().len()
+ skater.skeleton_drives.targets.bodies.len()
+ self.bodies.len();
let body_start = self.bodies.len();
for (i, (template, wire)) in physics
.board
.bodies()
.iter()
.chain(skater.skeleton.bodies())
.zip(&frame.bodies)
.enumerate()
{
let mut b = *template;
b.inertia = if i < 7 {
physics.settings.masses[i].dynamics
} else {
let part = skater.skeleton.definition.parts[i - 7];
let ragdoll = frame.enabled & (1 << 63) != 0 && (1..24).contains(&(i - 7));
let mut inertia = if ragdoll {
part.ragdoll.dynamics
} else {
part.animated.dynamics
};
inertia.inverse_mass = if ragdoll {
part.inverse_mass_ragdoll
} else {
part.inverse_mass_animated
};
inertia
};
let q = Quat::from_array(wire.pose.q).normalize();
b.rates.orientation = RetailQuaternion {
x: q.x,
y: q.y,
z: q.z,
w: q.w,
};
b.rates.basis = skate_core::math::Basis3 {
columns: Mat3::from_quat(q).to_cols_array_2d(),
};
b.rates.world_inverse_inertia =
world_inverse_inertia(b.rates.basis, b.inertia.inverse_tensor);
b.rates.position = add(vector(wire.pose.p), vector(wire.velocity.map(|v| v * age)));
b.rates.linear_velocity = vector(wire.velocity);
b.rates.angular_velocity = vector(wire.angular);
b.rates.force_acceleration = Vector3::ZERO;
b.rates.torque_acceleration = Vector3::ZERO;
b.state_flags = 4;
self.bodies.push(b);
}
for &(i, ref template) in &schema.volumes {
if frame.enabled & (1 << i) == 0 {
continue;
}
let b = &self.bodies[body_start + i];
self.volumes.push(BoardWorldVolume {
body: CollisionBody::Attached(base + i),
primitive: transform(template.primitive, matrix(body_pose(b))),
linear_velocity: b.rates.linear_velocity,
material: template.material,
});
}
}
}
@@ -113,7 +113,10 @@ fn collect(physics: &GamePhysics, skater: &SkaterRuntime) -> Vec<SkeletonContact
//82768418 rejects the same logical Body and equal nonnull Body+32
//owners. Board82C067F4 and Skeleton82BE4620 both store this actor.
//Their eligible cross contacts still participate in the shared solve.
if !matches!(a, CollisionBody::StaticWorld) && !matches!(b, CollisionBody::StaticWorld) {
let remote_base = skater.skeleton.bodies().len() + skater.skeleton_drives.targets.bodies.len();
let external = |body| matches!(body, CollisionBody::StaticWorld)
|| matches!(body, CollisionBody::Attached(index) if index >= remote_base);
if !external(a) && !external(b) {
continue;
}
for (own, other, side_a) in [(a, b, true), (b, a, false)] {
@@ -156,7 +159,9 @@ fn resolve<'a>(
match CollisionBody::from_contact_id(id) {
CollisionBody::StaticWorld => None,
CollisionBody::Board(id) => Some(&physics.board.bodies()[id.index()]),
CollisionBody::Attached(part) => Some(&skater.skeleton.bodies()[part]),
CollisionBody::Attached(part) => skater.skeleton.bodies().iter()
.chain(skater.skeleton_drives.targets.bodies.iter())
.chain(physics.network_proxies.bodies.iter()).nth(part),
}
}
fn contact_body(body: Option<&BodySnapshot>) -> SkeletonContactBody {
+14
View File
@@ -47,6 +47,19 @@ pub(super) fn advance(
physics.board.collision_group(),
&skater.skeleton_collision,
)?;
// Remote actors use separate reaction indices after skeleton and targets.
// Never apply the single-skater self-culling bitmap to another player.
let before_remote = contacts.len();
for a in board_volumes.iter().chain(&skeleton_volumes) {
for b in &physics.network_proxies.volumes {
let (ac, ar) = super::network::bounds(a.primitive);
let (bc, br) = super::network::bounds(b.primitive);
if ac.distance_squared(bc) <= (ar + br + 0.05).powi(2) {
assembly_contacts::append_pair(&mut contacts, a, b);
}
}
}
physics.network_contacts = contacts.len() - before_remote;
physics.contact_count = contacts.len();
let dt = physics.settings.step.simulation.time_step;
let mut joints =
@@ -74,6 +87,7 @@ pub(super) fn advance(
.bodies_mut()
.iter_mut()
.chain(skater.skeleton_drives.targets.bodies.iter_mut())
.chain(physics.network_proxies.bodies.iter_mut())
.collect();
physics.board.advance_attached(
&contacts,
@@ -75,7 +75,7 @@ fn group_pair_allowed(a: u32, b: u32) -> bool {
b < 21 && ALLOWED.get(a as usize).is_some_and(|row| row & (1 << b) != 0)
}
fn append_pair(contacts: &mut Vec<BoardCollision>, a: &BoardWorldVolume, b: &BoardWorldVolume) {
pub(super) fn append_pair(contacts: &mut Vec<BoardCollision>, a: &BoardWorldVolume, b: &BoardWorldVolume) {
//8277B0F0 and8277B2C0 enter the same8277A508 primitive walker with
//identical padding/triangle settings. Keep A/B orientation and point order.
let Some(manifold) = primitive_pair_contacts(
+5
View File
@@ -159,7 +159,12 @@ fn controls(
motion: Res<bevy::input::mouse::AccumulatedMouseMotion>,
time: Res<Time<Real>>,
menu: Option<Res<crate::graphics_menu::Menu>>,
net: Option<Res<crate::multiplayer::Multiplayer>>,
) {
if net.is_some_and(|n| n.active()) {
if replay.active {replay.exit();}
return;
}
let pad = input.raw_input();
let pressed = pad.buttons & !replay.previous_buttons;
replay.previous_buttons = pad.buttons;
+10 -5
View File
@@ -1,6 +1,6 @@
//! Retail world shading. See docs/retail-renderer.md for provenance and gaps.
use bevy::{
asset::embedded_asset,
asset::{AssetPath, embedded_asset, embedded_path},
core_pipeline::{
core_3d::graph::Node3d,
fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin},
@@ -16,6 +16,11 @@ use bevy::{
use std::collections::BTreeMap;
pub(crate) struct RetailRenderPlugin;
// Use the same path derivation as embedded_asset!: alternate binary targets
// have a different crate namespace even though they share these source files.
fn retail_shader(path: &str) -> ShaderRef {
AssetPath::from(embedded_path!(path)).with_source("embedded").into()
}
impl Plugin for RetailRenderPlugin {
fn build(&self, app: &mut App) {
embedded_asset!(app, "retail_world.wgsl");
@@ -49,7 +54,7 @@ impl Material for RetailSkyMaterial {
false
}
fn vertex_shader() -> ShaderRef {
"embedded://skate3rust/retail_sky.wgsl".into()
retail_shader("retail_sky.wgsl")
}
fn fragment_shader() -> ShaderRef {
Self::vertex_shader()
@@ -157,7 +162,7 @@ pub(crate) struct RetailTone {
}
impl FullscreenMaterial for RetailTone {
fn fragment_shader() -> ShaderRef {
"embedded://skate3rust/retail_tone.wgsl".into()
retail_shader("retail_tone.wgsl")
}
fn node_edges() -> Vec<InternedRenderLabel> {
vec![
@@ -225,10 +230,10 @@ impl From<&RetailWorldMaterial> for RetailKey {
}
impl Material for RetailWorldMaterial {
fn fragment_shader() -> ShaderRef {
"embedded://skate3rust/retail_world.wgsl".into()
retail_shader("retail_world.wgsl")
}
fn prepass_fragment_shader() -> ShaderRef {
"embedded://skate3rust/retail_depth.wgsl".into()
retail_shader("retail_depth.wgsl")
}
fn alpha_mode(&self) -> AlphaMode {
self.alpha
@@ -27,7 +27,7 @@ fn installed_worlds_prepare_commit_and_retire_without_simulation() {
};
let mut world = World::new();
world.insert_resource(Config { asset_root: root.clone(), verification_capture: None,
map: None, map_path: None, difficulty, check_assets: false, start_paused: false });
map: None, map_path: None, difficulty, check_assets: false, start_paused: false, multiplayer: Default::default(), map_fingerprint: crate::config::map_fingerprint(None).unwrap() });
world.insert_resource(CurrentMap::from_package(None, None));
world.insert_resource(graphs);
world.insert_resource(physics);
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "skate-net"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
half = "=2.7.1"
[dev-dependencies]
skate-core.workspace = true
@@ -0,0 +1,32 @@
//! Runs the production serializer on synthetic bounds; never starts the game or Steam.
use skate_net::{Body, Bone, Frame, Pose, Shape, Volume};
fn main() {
let pose = Pose { p: [0.; 3], q: [0., 0., 0., 1.] };
for (label, bones, volumes, name_len) in [
("30 anchors, no active collision volumes (lower bound)", 30, 0, 16),
("30 anchors, 32 capsules (illustrative, not a gameplay capture)", 30, 32, 16),
("30 anchors, 64 boxes (capture upper bound)", 30, 64, 128),
("32 anchors, 64 boxes (protocol upper bound)", 32, 64, 128),
] {
let frame = Frame {
map: 1, rig: 2, tick: 1, appearance: "x".repeat(name_len), root: pose,
bones: (0..bones).map(|index| Bone {index, pose}).collect(),
bodies: vec![Body {pose, velocity: [0.; 3], angular: [0.; 3]}; 33],
volumes: (0..volumes).map(|i| Volume {body: (i % 33) as u8, shape: if volumes == 32 {
Shape::Capsule {center: [0.; 3], axis: [0., 1., 0.], half: 0.2, radius: 0.1}
} else {Shape::Box {pose, half: [0.1; 3], radius: 0.01}}}).collect(),
};
let packets = skate_net::packets(1, 2, 1, &frame).unwrap();
let bytes: usize = packets.iter().map(Vec::len).sum();
println!("{label}: {} packets, {bytes} bytes/update including application headers", packets.len());
for hz in [30., 31.25] {
println!(" at {hz} Hz: {:.2} kB/s, {:.4} Mbps each direction; IPv4+UDP {:.4} Mbps (not Steam overhead)",
bytes as f64 * hz / 1000., bytes as f64 * hz * 8. / 1e6,
(bytes + packets.len() * 28) as f64 * hz * 8. / 1e6);
}
for loss in [0.01_f64, 0.05] {
println!(" independent {:.0}% fragment loss => {:.2}% incomplete snapshots", loss*100., (1. - (1. - loss).powi(packets.len() as i32))*100.);
}
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Synthetic ten-peer WAN test: production codec/session, no game or Steam launched.
use skate_net::{
Body, Bone, Pose,
lobby::{Info, Session},
packed::{self, BodyState, Packed, PoseState},
};
use std::collections::VecDeque;
fn pose(p: [f32; 3], t: f32) -> Pose {
Pose {
p,
q: [0., (t * 0.5).sin(), 0., (t * 0.5).cos()],
}
}
fn state(id: usize, time: u64, spread: f32, idle: bool) -> (Packed, Packed) {
let t = if idle { 0. } else { time as f32 / 1000. };
let root = pose([id as f32 * spread + t * 3., 0., 0.], t * 0.2);
let bodies = (0..33)
.map(|i| {
let phase = t * 3. + i as f32;
Body {
pose: pose(
[
root.p[0] + phase.sin() * 0.2,
i as f32 * 0.03,
phase.cos() * 0.1,
],
phase * 0.1,
),
velocity: [3. + phase.cos(), 0., phase.sin()],
angular: [0., phase.cos(), 0.],
}
})
.collect();
let bones = (0..30)
.map(|i| Bone {
index: i,
pose: pose(
[0., i as f32 * 0.03, (t * 3. + i as f32).sin() * 0.1],
t + i as f32,
),
})
.collect();
(
Packed::body(&BodyState {
root,
enabled: (1 << 33) - 1,
bodies,
})
.unwrap(),
Packed::pose(&PoseState { root, bones }).unwrap(),
)
}
fn info(id: u64) -> Info {
Info {
id,
map: 1,
rig: 2,
physics: 3,
appearance: 4,
}
}
struct Delivery {
at: u64,
from: usize,
to: usize,
data: Vec<u8>,
}
fn run(label: &str, spread: f32, loss: u64, idle: bool) {
let started = std::time::Instant::now();
let mut sessions: Vec<_> = (0..10)
.map(|i| Session::new(123, info(i + 1), (i != 0).then_some(1)))
.collect();
let mut queue = VecDeque::<Delivery>::new();
let mut rng = 9173u64;
let mut bytes = [[0u64; 2]; 10];
let mut packets = 0;
let mut max = 0;
let mut worst_age = 0;
let mut worst_pose_age = 0;
for time in (0..30_000).step_by(10) {
// Delivery order deliberately differs from send order (jitter and loss).
let mut i = 0;
while i < queue.len() {
if queue[i].at > time {
i += 1;
continue;
}
let p = queue.remove(i).unwrap();
if time >= 5000 {
bytes[p.to][1] += p.data.len() as u64;
}
sessions[p.to].receive(p.from as u64 + 1, &p.data, time);
}
for (i, s) in sessions.iter_mut().enumerate() {
if time % 50 == 0 {
let (body, pose) = state(i, time, spread, idle);
s.publish(packed::BODY, body, time);
if time % 100 == 0 {
s.publish(packed::POSE, pose, time);
}
}
for p in s.service(time) {
assert!(p.data.len() <= packed::MTU);
max = max.max(p.data.len());
packets += 1;
s.record_send(p.data.len(), true);
if time >= 5000 {
bytes[i][0] += p.data.len() as u64;
}
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
if (rng >> 32) % 100 < loss {
continue;
}
let delay = 30 + ((rng >> 40) % 61);
queue.push_back(Delivery {
at: time + delay,
from: i,
to: p.peer as usize - 1,
data: p.data,
});
}
}
if time >= 5000 {
for s in &sessions {
assert_eq!(s.actors.len(), 10, "lost membership: {label}");
for (&id, a) in &s.actors {
if id == s.local {
continue;
}
let body = a.body.latest().expect("Every player receives every actor");
let age = time - body.received;
worst_age = worst_age.max(age);
let pose_age = time - a.pose.latest().expect("pose available").received;
worst_pose_age = worst_pose_age.max(pose_age);
assert!(pose_age < 3500, "pose stalled: {pose_age}");
assert!(
age < if spread == 0. { 1200 } else { 2200 },
"stalled replication {label}: {age}"
);
}
}
}
}
let errors: u64 = sessions
.iter()
.map(|s| s.stats.invalid + s.stats.baseline_miss)
.sum();
assert_eq!(errors, 0, "codec errors / missing acknowledged baseline");
println!(
"{label}: host up/down {:.2}/{:.2} kB/s; guest avg {:.2}/{:.2} kB/s; max datagram {max}; max body/pose arrival age {worst_age}/{worst_pose_age} ms; {packets} packets; CPU wall {:?}",
bytes[0][0] as f64 / 25_000.,
bytes[0][1] as f64 / 25_000.,
bytes[1..].iter().map(|b| b[0]).sum::<u64>() as f64 / 225_000.,
bytes[1..].iter().map(|b| b[1]).sum::<u64>() as f64 / 225_000.,
started.elapsed()
);
}
fn main() {
run("10 active, clustered, 0% loss", 0., 0, false);
run("10 active, clustered, 5% loss", 0., 5, false);
run("10 active, spread 150m, 5% loss", 150., 5, false);
run("10 idle, clustered, 5% loss", 0., 5, true);
}
+180
View File
@@ -0,0 +1,180 @@
//! Fixed little-endian scalars, bounded counts, and no generic deserializer allocations.
use super::*;
struct Writer(Vec<u8>);
impl Writer {
fn byte(&mut self, v: u8) {
self.0.push(v);
}
fn floats<const N: usize>(&mut self, v: [f32; N]) {
for f in v {
self.0.extend(f.to_le_bytes());
}
}
fn pose(&mut self, p: Pose) {
self.floats(p.p);
self.floats(p.q);
}
}
pub(super) fn encode(f: &Frame) -> Vec<u8> {
let mut w = Writer(Vec::with_capacity(8192));
for v in [f.map, f.rig, f.tick] {
w.0.extend(v.to_le_bytes());
}
w.byte(f.appearance.len() as u8);
w.0.extend(f.appearance.as_bytes());
w.pose(f.root);
w.byte(f.bones.len() as u8);
for b in &f.bones {
w.0.extend(b.index.to_le_bytes());
w.pose(b.pose);
}
w.byte(f.bodies.len() as u8);
for b in &f.bodies {
w.pose(b.pose);
w.floats(b.velocity);
w.floats(b.angular);
}
w.byte(f.volumes.len() as u8);
for v in &f.volumes {
w.byte(v.body);
match v.shape {
Shape::Sphere { center, radius } => {
w.byte(0);
w.floats(center);
w.floats([radius]);
}
Shape::Capsule {
center,
axis,
half,
radius,
} => {
w.byte(1);
w.floats(center);
w.floats(axis);
w.floats([half, radius]);
}
Shape::Box { pose, half, radius } => {
w.byte(2);
w.pose(pose);
w.floats(half);
w.floats([radius]);
}
Shape::Triangle { vertices, radius } => {
w.byte(3);
for p in vertices {
w.floats(p);
}
w.floats([radius]);
}
}
}
w.0
}
struct Reader<'a>(&'a [u8]);
impl Reader<'_> {
fn bytes<const N: usize>(&mut self) -> Option<[u8; N]> {
let v = self.0.get(..N)?.try_into().ok()?;
self.0 = &self.0[N..];
Some(v)
}
fn byte(&mut self) -> Option<u8> {
Some(self.bytes::<1>()?[0])
}
fn floats<const N: usize>(&mut self) -> Option<[f32; N]> {
let mut v = [0.; N];
for f in &mut v {
*f = f32::from_le_bytes(self.bytes()?);
}
Some(v)
}
fn pose(&mut self) -> Option<Pose> {
Some(Pose {
p: self.floats()?,
q: self.floats()?,
})
}
}
pub(super) fn decode(data: &[u8]) -> Option<Frame> {
if data.len() > MAX_FRAME {
return None;
}
let mut r = Reader(data);
let map = u64::from_le_bytes(r.bytes()?);
let rig = u64::from_le_bytes(r.bytes()?);
let tick = u64::from_le_bytes(r.bytes()?);
let count = r.byte()? as usize;
if count > 128 {
return None;
}
let appearance = std::str::from_utf8(r.0.get(..count)?).ok()?.to_string();
r.0 = &r.0[count..];
let root = r.pose()?;
let count = r.byte()?;
if count > 32 {
return None;
}
let mut bones = Vec::with_capacity(count as usize);
for _ in 0..count {
bones.push(Bone {
index: u16::from_le_bytes(r.bytes()?),
pose: r.pose()?,
});
}
let count = r.byte()?;
if count as usize != BODY_COUNT {
return None;
}
let mut bodies = Vec::with_capacity(BODY_COUNT);
for _ in 0..count {
bodies.push(Body {
pose: r.pose()?,
velocity: r.floats()?,
angular: r.floats()?,
});
}
let count = r.byte()?;
if count > 64 {
return None;
}
let mut volumes = Vec::with_capacity(count as usize);
for _ in 0..count {
let body = r.byte()?;
let shape = match r.byte()? {
0 => Shape::Sphere {
center: r.floats()?,
radius: r.floats::<1>()?[0],
},
1 => Shape::Capsule {
center: r.floats()?,
axis: r.floats()?,
half: r.floats::<1>()?[0],
radius: r.floats::<1>()?[0],
},
2 => Shape::Box {
pose: r.pose()?,
half: r.floats()?,
radius: r.floats::<1>()?[0],
},
3 => Shape::Triangle {
vertices: [r.floats()?, r.floats()?, r.floats()?],
radius: r.floats::<1>()?[0],
},
_ => return None,
};
volumes.push(Volume { body, shape });
}
if !r.0.is_empty() {
return None;
}
Some(Frame {
map,
rig,
tick,
appearance,
root,
bones,
bodies,
volumes,
})
}
+168
View File
@@ -0,0 +1,168 @@
//! Bounded control messages between the game and the optional discovery helper.
use serde::{Deserialize, Serialize};
pub const NAMESPACE: &str = "skate3rust-free-skate-v4";
pub const PAGE_SIZE: usize = 5;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub id: u64,
pub command: Command,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Command {
Browse {
page: usize,
map: u64,
physics: u64,
},
Host {
map: u64,
physics: u64,
label: String,
},
Join {
lobby: u64,
map: u64,
physics: u64,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Row {
pub id: u64,
pub map: String,
pub players: usize,
pub capacity: usize,
pub compatible: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
Rows {
page: usize,
total: usize,
rows: Vec<Row>,
},
Owner {
lobby: u64,
owner: u64,
own: u64,
},
Error(String),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Response {
pub request: u64,
pub event: Event,
}
pub fn encode<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("Control serialization")
}
pub fn request(text: &str) -> Option<Request> {
if text.len() > 1200 {
return None;
}
let r: Request = serde_json::from_str(text).ok()?;
match &r.command {
Command::Browse { page, .. } if *page > 100 => return None,
Command::Host { label, .. } if label.len() > 64 || label.contains('\0') => return None,
_ => (),
};
Some(r)
}
pub fn response(text: &str) -> Option<Response> {
if text.len() > 1400 {
return None;
}
let r: Response = serde_json::from_str(text).ok()?;
match &r.event {
Event::Rows { rows, .. }
if rows.len() > PAGE_SIZE
|| rows
.iter()
.any(|r| r.map.len() > 64 || r.capacity > 10 || r.players > 10) =>
{
return None;
}
Event::Owner { lobby, owner, own } if *lobby == 0 || *owner == 0 || *own == 0 => {
return None;
}
_ => (),
};
Some(r)
}
/// Keep labels readable and bounded in bytes, including Unicode map names.
pub fn label(text: &str) -> String {
let mut result = String::new();
for c in text.chars().filter(|c| !c.is_control()).take(32) {
if result.len() + c.len_utf8() > 48 {
break;
}
result.push(c);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unicode_labels_and_full_page_fit_control_datagram() {
let name = label(&"\u{1f600}".repeat(80));
assert!(name.len() <= 48);
let rows = (1..=5)
.map(|id| Row {
id,
map: name.clone(),
players: 10,
capacity: 10,
compatible: true,
})
.collect();
assert!(
response(&encode(&Response {
request: 1,
event: Event::Rows {
page: 0,
total: 5,
rows
}
}))
.is_some()
);
assert!(
request(&encode(&Request {
id: 1,
command: Command::Host {
map: 1,
physics: 2,
label: name
}
}))
.is_some()
);
}
#[test]
fn rejects_invalid_owners_and_unbounded_requests() {
assert!(
response(&encode(&Response {
request: 0,
event: Event::Owner {
lobby: 1,
owner: 0,
own: 2
}
}))
.is_none()
);
assert!(
request(&encode(&Request {
id: 1,
command: Command::Browse {
page: 101,
map: 1,
physics: 2
}
}))
.is_none()
);
assert!(request(&"x".repeat(1201)).is_none());
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Rendering-only snapshot timing. Source seconds and local arrival seconds are
//! distinct clocks; no synchronised system clocks or platform transport needed.
use std::collections::VecDeque;
#[derive(Clone, Debug)]
pub struct Sample<T> {
pub time: f64,
pub value: T,
}
#[derive(Debug)]
pub struct Buffer<T> {
pub samples: VecDeque<Sample<T>>,
}
impl<T> Default for Buffer<T> {
fn default() -> Self {
Self {
samples: VecDeque::new(),
}
}
}
impl<T> Buffer<T> {
pub fn insert(&mut self, time: f64, value: T) -> bool {
if !time.is_finite() || self.samples.back().is_some_and(|s| time <= s.time) {
return false;
}
self.samples.push_back(Sample { time, value });
while self.samples.len() > 64 {
self.samples.pop_front();
}
true
}
pub fn pair(&self, time: f64) -> Option<(usize, usize, f32)> {
let first = self.samples.front()?;
if time <= first.time {
return Some((0, 0, 0.));
}
let upper = self.samples.partition_point(|s| s.time <= time);
if upper == self.samples.len() {
return Some((upper - 1, upper - 1, 0.));
}
let a = &self.samples[upper - 1];
let b = &self.samples[upper];
Some((
upper - 1,
upper,
((time - a.time) / (b.time - a.time)).clamp(0., 1.) as f32,
))
}
}
#[derive(Default, Debug)]
struct Delivery {
last: Option<(f64, f64)>,
gaps: VecDeque<f64>,
jitter: f64,
}
impl Delivery {
fn observe(&mut self, source: f64, local: f64) {
if let Some((s, l)) = self.last {
if source <= s {
return;
}
let gap = source - s;
self.gaps.push_back(gap.clamp(0.01, 2.));
while self.gaps.len() > 16 {
self.gaps.pop_front();
}
self.jitter += (((local - l) - gap).abs().min(1.) - self.jitter) * 0.1;
}
self.last = Some((source, local));
}
fn interval(&self, default: f64) -> f64 {
let mut gaps: Vec<_> = self.gaps.iter().copied().collect();
gaps.sort_by(f64::total_cmp);
if gaps.is_empty() {
default
} else {
gaps[gaps.len() / 2]
}
}
}
#[derive(Default, Debug)]
pub struct Clock {
delivery: [Delivery; 2],
cursor: Option<f64>,
local: Option<f64>,
drift: f64,
pub delay: f64,
pub underruns: u64,
starving: bool,
loopback: bool,
}
impl Clock {
pub fn for_connection(loopback: bool) -> Self {
Self {
loopback,
..Self::default()
}
}
pub fn observe(&mut self, stream: usize, source: f64, local: f64) {
if stream < 2 && source.is_finite() && local.is_finite() {
self.delivery[stream].observe(source, local);
}
}
pub fn step(&mut self, local: f64) -> Option<f64> {
if !local.is_finite() {
return self.cursor;
}
let head = self
.delivery
.iter()
.filter_map(|d| d.last.map(|(s, _)| s))
.reduce(f64::min)?;
let interval = self.delivery[0]
.interval(0.05)
.max(self.delivery[1].interval(if self.loopback { 0.05 } else { 0.1 }));
let jitter = self.delivery.iter().map(|d| d.jitter).fold(0., f64::max);
self.delay = if self.loopback {
(1.25 * interval + 2. * jitter).clamp(0.06, 0.15)
} else {
(1.5 * interval + 2. * jitter).clamp(0.15, 2.5)
};
let dt = self.local.map_or(0., |last| (local - last).max(0.));
self.local = Some(local);
let Some(cursor) = self.cursor else {
self.cursor = Some(head - self.delay);
return self.cursor;
};
// A suspended render loop resumes from a fresh buffer, never replays seconds of backlog.
if dt > 0.5 || head - cursor > self.delay + if self.loopback { 0.25 } else { 0.75 } {
self.cursor = Some(head - self.delay);
self.drift = 0.;
self.starving = false;
return self.cursor;
}
let error = head - cursor - self.delay;
self.drift += (error - self.drift) * (dt / (0.35 + dt));
let threshold = (interval * 0.35).max(0.02);
let speed = if self.drift > threshold {
1.08
} else if self.drift < -threshold {
0.92
} else {
1.
};
let next = cursor + dt * speed;
if next > head {
if !self.starving {
self.underruns += 1;
}
self.starving = true;
} else {
self.starving = false;
}
self.cursor = Some(next.min(head).max(cursor));
self.cursor
}
}
/// Limited cubic Hermite position interpolation. Tangents come from root
/// positions at their own timestamps, not velocities of an unrelated COM.
/// Limiting prevents curves overshooting the two endpoints at stops/impacts.
pub fn position(buffer: &Buffer<[f32; 3]>, time: f64) -> Option<[f32; 3]> {
let (a, b, u) = buffer.pair(time)?;
let x = &buffer.samples[a];
let y = &buffer.samples[b];
if a == b {
return Some(x.value);
}
let before = &buffer.samples[a.saturating_sub(1)];
let after = buffer.samples.get(b + 1).unwrap_or(y);
let dt = (y.time - x.time) as f32;
Some(std::array::from_fn(|i| {
let delta = y.value[i] - x.value[i];
if delta.abs() < 1e-7 {
return x.value[i];
}
let mut m0 = (y.value[i] - before.value[i]) / ((y.time - before.time) as f32) * dt;
let mut m1 = (after.value[i] - x.value[i]) / ((after.time - x.time) as f32) * dt;
if m0 / delta < 0. {
m0 = 0.;
}
if m1 / delta < 0. {
m1 = 0.;
}
let length = ((m0 / delta).powi(2) + (m1 / delta).powi(2)).sqrt();
if length > 3. {
m0 *= 3. / length;
m1 *= 3. / length;
}
let u2 = u * u;
let u3 = u2 * u;
(2. * u3 - 3. * u2 + 1.) * x.value[i]
+ (u3 - 2. * u2 + u) * m0
+ (-2. * u3 + 3. * u2) * y.value[i]
+ (u3 - u2) * m1
}))
}
+399
View File
@@ -0,0 +1,399 @@
//! Transport-neutral, bounded snapshot protocol. No platform identity or SDK types.
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
mod codec;
pub mod lobby;
pub mod directory;
pub mod packed;
pub mod interpolation;
pub const MAGIC: &[u8; 8] = b"SK8NET01";
pub const MAX_FRAME: usize = 48_000;
const PAYLOAD: usize = 1_000;
const HEADER: usize = 36;
pub const BODY_COUNT: usize = 33;
pub const DEFAULT_APPEARANCE: &str = "default-skater-v1";
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct Pose {
pub p: [f32; 3],
pub q: [f32; 4],
}
impl Pose {
pub fn valid(&self) -> bool {
self.p.iter().all(|v| v.is_finite() && v.abs() < 100_000.)
&& self.q.iter().all(|v| v.is_finite())
&& (0.98..1.02).contains(&self.q.iter().map(|v| v * v).sum::<f32>())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Body {
pub pose: Pose,
pub velocity: [f32; 3],
pub angular: [f32; 3],
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bone {
pub index: u16,
pub pose: Pose,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Shape {
Sphere {
center: [f32; 3],
radius: f32,
},
Capsule {
center: [f32; 3],
axis: [f32; 3],
half: f32,
radius: f32,
},
Box {
pose: Pose,
half: [f32; 3],
radius: f32,
},
Triangle {
vertices: [[f32; 3]; 3],
radius: f32,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Volume {
pub body: u8,
pub shape: Shape,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Frame {
pub map: u64,
pub rig: u64,
pub tick: u64,
pub appearance: String,
pub root: Pose,
/// Physical animation anchors and board parts, never the complete skin.
pub bones: Vec<Bone>,
pub bodies: Vec<Body>,
pub volumes: Vec<Volume>,
}
fn vector(v: &[f32; 3], bound: f32) -> bool {
v.iter().all(|x| x.is_finite() && x.abs() <= bound)
}
impl Frame {
pub fn validate(&self) -> bool {
if self.appearance.len() > 128
|| !self.root.valid()
|| self.bones.len() > 32
|| self.bodies.len() != BODY_COUNT
|| self.volumes.len() > 64
{
return false;
}
if !self.bones.iter().all(|b| b.index < 256 && b.pose.valid()) {
return false;
}
if self
.bones
.iter()
.enumerate()
.any(|(i, b)| self.bones[..i].iter().any(|a| a.index == b.index))
{
return false;
}
if !self
.bodies
.iter()
.all(|b| b.pose.valid() && vector(&b.velocity, 250.) && vector(&b.angular, 500.))
{
return false;
}
self.volumes.iter().all(|v| {
let Some(body) = self.bodies.get(v.body as usize) else {
return false;
};
let near = |p: &[f32; 3]| {
vector(p, 100_000.)
&& p.iter()
.zip(body.pose.p)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f32>()
< 16.
};
let radius = |r: f32| r.is_finite() && (0.0..=1.0).contains(&r);
match &v.shape {
Shape::Sphere { center, radius: r } => near(center) && radius(*r),
Shape::Capsule {
center,
axis,
half,
radius: r,
} => {
near(center)
&& vector(axis, 1.01)
&& (0.98..1.02).contains(&axis.iter().map(|v| v * v).sum::<f32>())
&& radius(*half)
&& radius(*r)
}
Shape::Box {
pose,
half,
radius: r,
} => pose.valid() && near(&pose.p) && half.iter().all(|v| radius(*v)) && radius(*r),
Shape::Triangle {
vertices,
radius: r,
} => vertices.iter().all(near) && radius(*r),
}
})
}
}
pub fn appearance_or_default(requested: &str) -> &'static str {
// Only the canonical rig/outfit is implemented. Never turn a peer identifier into a path.
let _ = requested;
DEFAULT_APPEARANCE
}
pub fn hash(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf29ce484222325, |h, b| {
(h ^ u64::from(*b)).wrapping_mul(0x100000001b3)
})
}
pub fn packets(
session: u64,
node: u64,
sequence: u64,
frame: &Frame,
) -> Result<Vec<Vec<u8>>, String> {
if !frame.validate() {
return Err("Invalid snapshot".into());
}
let data = codec::encode(frame);
if !frame.validate() || data.len() > MAX_FRAME {
return Err("Snapshot exceeds protocol bounds".into());
}
let count = data.len().div_ceil(PAYLOAD) as u16;
Ok(data
.chunks(PAYLOAD)
.enumerate()
.map(|(index, chunk)| {
let mut out = Vec::with_capacity(HEADER + chunk.len());
out.extend(MAGIC);
out.extend(session.to_le_bytes());
out.extend(node.to_le_bytes());
out.extend(sequence.to_le_bytes());
out.extend((index as u16).to_le_bytes());
out.extend(count.to_le_bytes());
out.extend(chunk);
out
})
.collect())
}
pub fn envelope(packet: &[u8]) -> Option<(u64, u64, u64)> {
if packet.len() <= HEADER || packet.len() > HEADER + PAYLOAD || &packet[..8] != MAGIC {
return None;
}
Some((
u64::from_le_bytes(packet[8..16].try_into().ok()?),
u64::from_le_bytes(packet[16..24].try_into().ok()?),
u64::from_le_bytes(packet[24..32].try_into().ok()?),
))
}
struct Partial {
sequence: u64,
count: usize,
chunks: Vec<Option<Vec<u8>>>,
start: Instant,
}
#[derive(Default)]
pub struct Receiver {
pending: Vec<Partial>,
completed: u64,
}
impl Receiver {
pub fn receive(&mut self, bytes: &[u8]) -> Option<Frame> {
let (_, _, sequence) = envelope(bytes)?;
if sequence <= self.completed {
return None;
}
let index = u16::from_le_bytes(bytes[32..34].try_into().ok()?) as usize;
let count = u16::from_le_bytes(bytes[34..36].try_into().ok()?) as usize;
if count == 0 || count > MAX_FRAME / PAYLOAD || index >= count {
return None;
}
self.pending.retain(|p| {
p.start.elapsed() < Duration::from_millis(500) && p.sequence > self.completed
});
if !self.pending.iter().any(|p| p.sequence == sequence) {
if self.pending.len() >= 4 {
self.pending.remove(0);
}
self.pending.push(Partial {
sequence,
count,
chunks: vec![None; count],
start: Instant::now(),
});
}
let p = self.pending.iter_mut().find(|p| p.sequence == sequence)?;
if p.count != count {
return None;
}
p.chunks[index] = Some(bytes[HEADER..].to_vec());
if p.chunks.iter().any(Option::is_none) {
return None;
}
let data: Vec<_> = p
.chunks
.iter()
.flat_map(|p| p.as_ref().unwrap().iter().copied())
.collect();
let frame = codec::decode(&data)?;
if !frame.validate() {
return None;
}
self.completed = sequence;
self.pending.retain(|p| p.sequence > sequence);
Some(frame)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame() -> Frame {
let pose = Pose {
p: [0.; 3],
q: [0., 0., 0., 1.],
};
Frame {
map: 1,
rig: 2,
tick: 3,
appearance: "uninstalled/outfit".into(),
root: pose,
bones: vec![Bone { index: 0, pose }],
bodies: vec![
Body {
pose,
velocity: [0.; 3],
angular: [0.; 3]
};
BODY_COUNT
],
volumes: vec![],
}
}
#[test]
fn reorder_duplicate_and_loss() {
let packets = packets(1, 2, 1, &frame()).unwrap();
let mut rx = Receiver::default();
for p in packets.iter().rev().skip(1) {
assert!(rx.receive(p).is_none());
assert!(rx.receive(p).is_none());
}
assert!(rx.receive(packets.last().unwrap()).is_some());
for p in &packets {
assert!(rx.receive(p).is_none());
}
let incomplete = super::packets(1, 2, 2, &frame()).unwrap();
rx.receive(&incomplete[0]);
let next = super::packets(1, 2, 3, &frame()).unwrap();
assert_eq!(next.iter().filter_map(|p| rx.receive(p)).count(), 1);
for p in &incomplete {
assert!(rx.receive(p).is_none());
}
}
#[test]
fn rejects_bad_numbers_sizes_and_indices() {
let mut f = frame();
f.root.q = [0.; 4];
assert!(!f.validate());
f = frame();
f.bodies[0].velocity[0] = f32::NAN;
assert!(!f.validate());
f = frame();
f.volumes.push(Volume {
body: 255,
shape: Shape::Sphere {
center: [0.; 3],
radius: 1.,
},
});
assert!(!f.validate());
f = frame();
f.bones.push(f.bones[0].clone());
assert!(!f.validate());
assert!(envelope(&[0; 2048]).is_none());
assert_eq!(appearance_or_default("../../bad.glb"), DEFAULT_APPEARANCE);
}
#[test]
fn binary_geometry_round_trip_and_truncation() {
let mut f = frame();
f.volumes = vec![
Volume {
body: 0,
shape: Shape::Sphere {
center: [0.; 3],
radius: 0.1,
},
},
Volume {
body: 1,
shape: Shape::Capsule {
center: [0.; 3],
axis: [0., 1., 0.],
half: 0.2,
radius: 0.1,
},
},
Volume {
body: 2,
shape: Shape::Box {
pose: f.root,
half: [0.1; 3],
radius: 0.01,
},
},
Volume {
body: 3,
shape: Shape::Triangle {
vertices: [[0.; 3], [0.1, 0., 0.], [0., 0.1, 0.]],
radius: 0.01,
},
},
];
let data = codec::encode(&f);
assert!(data.len() < 3000);
assert_eq!(codec::encode(&codec::decode(&data).unwrap()), data);
for n in 0..data.len() {
assert!(codec::decode(&data[..n]).is_none());
}
let mut trailing = data;
trailing.push(0);
assert!(codec::decode(&trailing).is_none());
}
#[test]
fn actual_loopback_datagrams_reassemble() {
use std::net::UdpSocket;
let a = UdpSocket::bind("127.0.0.1:0").unwrap();
let b = UdpSocket::bind("127.0.0.1:0").unwrap();
a.connect(b.local_addr().unwrap()).unwrap();
b.connect(a.local_addr().unwrap()).unwrap();
b.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
let packets = packets(12, 34, 1, &frame()).unwrap();
for p in packets.iter().rev() {
a.send(p).unwrap();
}
let mut rx = Receiver::default();
let mut buffer = [0; 1500];
let mut complete = 0;
for _ in &packets {
let n = b.recv(&mut buffer).unwrap();
assert_eq!(envelope(&buffer[..n]).unwrap(), (12, 34, 1));
if let Some(f) = rx.receive(&buffer[..n]) {
assert_eq!(f.tick, 3);
complete += 1;
}
}
assert_eq!(complete, 1);
}
}
+649
View File
@@ -0,0 +1,649 @@
//! Transport-neutral ten-player star lobby. The host admits and forwards;
//! each actor retains local physics ownership. No platform SDK or wall clock.
use crate::packed::{self, BODY, HEADER, POSE, Packed, Reader};
use std::collections::{BTreeMap, VecDeque};
pub const MAX_PLAYERS: usize = 10;
pub const HELLO: u8 = 3;
pub const ROSTER: u8 = 4;
pub const ACK: u8 = 5;
pub const PING: u8 = 6;
pub const PONG: u8 = 7;
pub const REJECT: u8 = 8;
pub const GOODBYE: u8 = 9;
pub const DEFAULT_LINK_BUDGET: f64 = 180_000.;
pub const DEFAULT_HOST_BUDGET: f64 = 1_000_000.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Info {
pub id: u64,
pub map: u64,
pub rig: u64,
pub physics: u64,
pub appearance: u64,
}
#[derive(Clone, Debug)]
pub struct Revision {
pub seq: u32,
pub state: Packed,
pub received: u64,
interest: Vec<[f32; 3]>,
}
#[derive(Default)]
pub struct Stream {
pub history: VecDeque<Revision>,
pub changed: u64,
}
impl Stream {
pub fn latest(&self) -> Option<&Revision> {
self.history.back()
}
fn at(&self, seq: u32) -> Option<&Packed> {
self.history.iter().find(|r| r.seq == seq).map(|r| &r.state)
}
fn push(&mut self, seq: u32, state: Packed, now: u64) {
if self.latest().is_none_or(|r| !r.state.same_state(&state)) {
self.changed = now;
}
let mut interest = vec![state.position()];
if state.rows.len() == 33 {
if let Some(body) = state.unpack_body() {
interest.extend(body.bodies.iter().take(7).map(|b| b.pose.p));
}
}
self.history.push_back(Revision {
interest,
seq,
state,
received: now,
});
while self.history.len() > 64 {
self.history.pop_front();
}
}
}
pub struct Actor {
pub info: Info,
pub body: Stream,
pub pose: Stream,
}
impl Actor {
fn new(info: Info) -> Self {
Self {
info,
body: Stream::default(),
pose: Stream::default(),
}
}
pub fn stream(&self, kind: u8) -> &Stream {
if kind == BODY { &self.body } else { &self.pose }
}
fn stream_mut(&mut self, kind: u8) -> &mut Stream {
if kind == BODY {
&mut self.body
} else {
&mut self.pose
}
}
}
struct Link {
actor: u64,
seen: u64,
acks: BTreeMap<(u64, u8), u32>,
sent: BTreeMap<(u64, u8), (u64, u32)>,
keys: BTreeMap<(u64, u8), u64>,
credits: f64,
last_ack: u64,
last_ping: u64,
rtt: u64,
}
impl Link {
fn new(actor: u64, now: u64) -> Self {
Self {
actor,
seen: now,
acks: BTreeMap::new(),
sent: BTreeMap::new(),
keys: BTreeMap::new(),
credits: 2400.,
last_ack: 0,
last_ping: 0,
rtt: 0,
}
}
}
#[derive(Default, Clone, Debug)]
pub struct Stats {
pub tx_bytes: u64,
pub rx_bytes: u64,
pub decoded: u64,
pub late: u64,
pub baseline_miss: u64,
pub invalid: u64,
pub budget_skips: u64,
pub send_errors: u64,
pub rtt_ms: u64,
}
pub struct Outgoing {
pub peer: u64,
pub data: Vec<u8>,
}
pub struct Session {
pub local: u64,
pub session: u64,
pub actors: BTreeMap<u64, Actor>,
pub stats: Stats,
pub notice: String,
host: Option<u64>,
links: BTreeMap<u64, Link>,
pending: Vec<Outgoing>,
last_hello: u64,
last_roster: u64,
roster_seq: u32,
received_roster: u32,
last_service: u64,
credits: f64,
budget: f64,
link_budget: f64,
round: usize,
loopback: bool,
}
impl Session {
pub fn new(session: u64, info: Info, host: Option<u64>) -> Self {
let mut actors = BTreeMap::new();
actors.insert(info.id, Actor::new(info));
let mut links = BTreeMap::new();
if let Some(peer) = host {
links.insert(peer, Link::new(0, 0));
}
Self {
local: info.id,
session,
actors,
host,
links,
stats: Stats::default(),
notice: String::new(),
pending: vec![],
last_hello: 0,
last_roster: 0,
roster_seq: 0,
received_roster: 0,
last_service: 0,
credits: 12_000.,
budget: if host.is_none() {
DEFAULT_HOST_BUDGET
} else {
DEFAULT_LINK_BUDGET
},
link_budget: DEFAULT_LINK_BUDGET,
round: 0,
loopback: false,
}
}
/// An external membership authority chooses the new endpoint. Preserve our
/// actor/capture history; rebuild membership and recipient delta baselines.
pub fn migrate(&mut self, host: Option<u64>, now: u64) {
self.host = host;
self.links.clear();
if let Some(peer) = host {
self.links.insert(peer, Link::new(0, now));
}
self.actors.retain(|id, _| *id == self.local);
self.pending.clear();
self.received_roster = 0;
self.roster_seq = 0;
self.last_hello = 0;
self.last_roster = 0;
self.last_service = now;
self.budget = if host.is_none() {
DEFAULT_HOST_BUDGET
} else {
DEFAULT_LINK_BUDGET
};
self.credits = 12_000.;
self.notice = if host.is_none() {
String::new()
} else {
"Host changed; reconnecting players...".into()
};
}
pub fn set_loopback(&mut self, enabled: bool) {
self.loopback = enabled;
}
pub fn is_host(&self) -> bool {
self.host.is_none()
}
pub fn connected(&self) -> bool {
self.is_host() || self.received_roster != 0
}
pub fn set_congested(&mut self, congested: bool) {
self.link_budget = if congested {
DEFAULT_LINK_BUDGET * 0.5
} else {
DEFAULT_LINK_BUDGET
};
}
pub fn publish(&mut self, kind: u8, mut state: Packed, now: u64) {
state.captured = now;
let stream = self.actors.get_mut(&self.local).unwrap().stream_mut(kind);
let seq = stream.latest().map_or(1, |r| {
r.seq.checked_add(1).expect("Session sequence exhausted")
});
stream.push(seq, state, now);
}
pub fn record_send(&mut self, len: usize, success: bool) {
if success {
self.stats.tx_bytes += len as u64;
} else {
self.stats.send_errors += 1;
}
}
fn queue(&mut self, peer: u64, data: Vec<u8>) {
if self.pending.len() < 64 && data.len() <= packed::MTU {
self.pending.push(Outgoing { peer, data });
}
}
fn local_info(&self) -> Info {
self.actors[&self.local].info
}
pub fn receive(&mut self, peer: u64, data: &[u8], now: u64) {
self.stats.rx_bytes += data.len() as u64;
let Some((session, actor, kind, seq)) = packed::envelope(data) else {
self.stats.invalid += 1;
return;
};
if session != self.session || actor == 0 {
return;
}
let mut r = Reader(&data[HEADER..]);
if kind == HELLO && self.is_host() {
let Some(info) = read_info(&mut r) else {
return;
};
if !r.0.is_empty() || info.id != actor || actor == self.local {
return;
}
let own = self.local_info();
let error = if info.map != own.map {
1
} else if info.physics != own.physics {
2
} else if !self.links.contains_key(&peer) && self.links.len() >= MAX_PLAYERS - 1 {
3
} else {
0
};
if error != 0 {
let mut b = packed::header(self.session, self.local, REJECT, 0);
b.push(error);
self.queue(peer, b);
return;
}
if self
.links
.iter()
.any(|(&endpoint, l)| endpoint != peer && l.actor == actor)
{
return;
}
let old = self.links.get(&peer).map(|l| l.actor);
if old != Some(actor) {
if let Some(old) = old {
self.actors.remove(&old);
}
self.links.insert(peer, Link::new(actor, now));
self.actors.insert(actor, Actor::new(info));
self.last_roster = 0;
}
self.links.get_mut(&peer).unwrap().seen = now;
self.notice.clear();
return;
}
if self.host.is_some_and(|h| h != peer) {
return;
}
if !self.links.contains_key(&peer) {
return;
}
if kind == ROSTER && self.host == Some(peer) {
if seq <= self.received_roster {
return;
}
let Some(count) = r.byte() else {
return;
};
if count == 0 || count as usize > MAX_PLAYERS {
return;
}
let mut infos = vec![];
for _ in 0..count {
let Some(info) = read_info(&mut r) else {
return;
};
if info.id == 0
|| infos.iter().any(|i: &Info| i.id == info.id)
|| info.map != self.local_info().map
|| info.physics != self.local_info().physics
{
return;
}
infos.push(info);
}
if !r.0.is_empty() || infos[0].id != actor || !infos.iter().any(|i| i.id == self.local)
{
return;
}
self.received_roster = seq;
self.links.get_mut(&peer).unwrap().actor = actor;
self.links.get_mut(&peer).unwrap().seen = now;
self.actors
.retain(|id, _| infos.iter().any(|i| i.id == *id));
for info in infos {
self.actors
.entry(info.id)
.or_insert_with(|| Actor::new(info));
}
self.notice.clear();
return;
}
if kind == REJECT && self.host == Some(peer) {
self.notice = match r.byte() {
Some(1) => "Map mismatch",
Some(2) => "Physics definition mismatch",
Some(3) => "Lobby full (10 players)",
_ => "Lobby rejected connection",
}
.into();
return;
}
if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE) {
return;
}
if kind == GOODBYE {
if self.is_host() {
self.links.remove(&peer);
self.actors.remove(&actor);
self.last_roster = 0;
} else {
self.actors.retain(|id, _| *id == self.local);
self.received_roster = 0;
self.notice = "Host left. Join another lobby.".into();
}
return;
}
if kind == PING {
let Some(stamp) = r.u64() else {
return;
};
if !r.0.is_empty() {
return;
}
let mut b = packed::header(self.session, self.local, PONG, 0);
b.extend(stamp.to_le_bytes());
self.queue(peer, b);
self.links.get_mut(&peer).unwrap().seen = now;
return;
}
if kind == PONG {
let Some(stamp) = r.u64() else {
return;
};
if !r.0.is_empty() || stamp > now || now - stamp > 5000 {
return;
}
self.links.get_mut(&peer).unwrap().rtt = now - stamp;
self.links.get_mut(&peer).unwrap().seen = now;
return;
}
if kind == ACK {
let Some(count) = r.byte() else {
return;
};
if count as usize > MAX_PLAYERS {
return;
}
let mut ack = vec![];
for _ in 0..count {
let (Some(id), Some(body), Some(pose)) = (r.u64(), r.u32(), r.u32()) else {
return;
};
ack.push((id, body, pose));
}
if !r.0.is_empty() {
return;
}
let link = self.links.get_mut(&peer).unwrap();
link.seen = now;
for (id, body, pose) in ack {
if self.actors.contains_key(&id) {
link.acks.insert((id, BODY), body);
link.acks.insert((id, POSE), pose);
}
}
return;
}
if !matches!(kind, BODY | POSE) || actor == self.local || !self.actors.contains_key(&actor)
{
return;
}
if self.is_host() && self.links[&peer].actor != actor {
return;
}
let stream = self.actors.get_mut(&actor).unwrap().stream_mut(kind);
if stream.latest().is_some_and(|s| seq <= s.seq) {
self.stats.late += 1;
return;
}
let Some(base) = packed::baseline(data) else {
return;
};
let baseline = if base == 0 { None } else { stream.at(base) };
if base != 0 && baseline.is_none() {
self.stats.baseline_miss += 1;
return;
}
let Some(state) = packed::apply(data, baseline) else {
self.stats.invalid += 1;
return;
};
stream.push(seq, state, now);
self.stats.decoded += 1;
self.links.get_mut(&peer).unwrap().seen = now;
}
pub fn goodbye(&self) -> Vec<Outgoing> {
self.links
.keys()
.map(|&peer| Outgoing {
peer,
data: packed::header(self.session, self.local, GOODBYE, 0),
})
.collect()
}
pub fn service(&mut self, now: u64) -> Vec<Outgoing> {
let dt = now.saturating_sub(self.last_service).min(1000) as f64 / 1000.;
self.last_service = now;
self.credits = (self.credits + dt * self.budget).min(12_000.);
for l in self.links.values_mut() {
l.credits = (l.credits + dt * self.link_budget).min(3600.);
}
if self.is_host() {
let expired: Vec<_> = self
.links
.iter()
.filter(|(_, l)| now.saturating_sub(l.seen) > 5000)
.map(|(&p, l)| (p, l.actor))
.collect();
for (peer, id) in expired {
self.links.remove(&peer);
self.actors.remove(&id);
self.last_roster = 0;
}
} else if self
.links
.values()
.any(|l| now.saturating_sub(l.seen) > 5000)
{
self.actors.retain(|id, _| *id == self.local);
self.received_roster = 0;
self.notice = "Host unavailable; waiting to reconnect".into();
}
if let Some(peer) = self.host {
if now.saturating_sub(self.last_hello) >= 500 || self.last_hello == 0 {
let mut b = packed::header(self.session, self.local, HELLO, 0);
write_info(&mut b, self.local_info());
self.queue(peer, b);
self.last_hello = now.max(1);
}
} else if now.saturating_sub(self.last_roster) >= 500 || self.last_roster == 0 {
self.roster_seq = self.roster_seq.wrapping_add(1);
let mut b = packed::header(self.session, self.local, ROSTER, self.roster_seq);
b.push(self.actors.len() as u8);
write_info(&mut b, self.local_info());
for (&id, a) in &self.actors {
if id != self.local {
write_info(&mut b, a.info);
}
}
for peer in self.links.keys().copied().collect::<Vec<_>>() {
self.queue(peer, b.clone());
}
self.last_roster = now.max(1);
}
let mut output = std::mem::take(&mut self.pending);
let mut ids: Vec<_> = self.actors.keys().copied().collect();
if !ids.is_empty() {
let offset = self.round % ids.len();
ids.rotate_left(offset);
}
self.round = self.round.wrapping_add(1);
let mut peers: Vec<_> = self.links.keys().copied().collect();
if !peers.is_empty() {
let offset = self.round % peers.len();
peers.rotate_left(offset);
}
for peer in peers {
let link = self.links.get_mut(&peer).unwrap();
if now.saturating_sub(link.last_ack) >= 100 {
let mut b = packed::header(self.session, self.local, ACK, 0);
b.push(self.actors.len() as u8);
for (&id, a) in &self.actors {
b.extend(id.to_le_bytes());
b.extend(a.body.latest().map_or(0, |r| r.seq).to_le_bytes());
b.extend(a.pose.latest().map_or(0, |r| r.seq).to_le_bytes());
}
output.push(Outgoing { peer, data: b });
link.last_ack = now;
}
if now.saturating_sub(link.last_ping) >= 1000 {
let mut b = packed::header(self.session, self.local, PING, 0);
b.extend(now.to_le_bytes());
output.push(Outgoing { peer, data: b });
link.last_ping = now;
}
// Essential physics first; optional pose detail consumes only remaining budget.
for kind in [BODY, POSE] {
for &id in &ids {
if id == link.actor || (self.host.is_some() && id != self.local) {
continue;
}
let actor = &self.actors[&id];
let stream = actor.stream(kind);
let Some(latest) = stream.latest() else {
continue;
};
let distance = if self.host.is_some() {
0.
} else {
self.actors
.get(&link.actor)
.and_then(|a| a.body.latest())
.zip(actor.body.latest())
.map_or(0., |(a, b)| {
// Detached boards also need frequent updates near another player.
let roots = distance(a.interest[0], b.interest[0]);
a.interest
.iter()
.map(|&p| distance(p, b.interest[0]))
.chain(b.interest.iter().map(|&p| distance(p, a.interest[0])))
.fold(roots, f32::min)
})
};
let mut interval = match (kind, distance) {
(BODY, d) if d < 30. => 50,
(BODY, d) if d < 100. => 100,
(BODY, _) => 500,
(POSE, d) if d < 30. => {
if self.loopback {
50
} else {
100
}
}
(POSE, d) if d < 100. => 250,
_ => 1000,
};
if !self.loopback
&& now.saturating_sub(stream.changed) > 250
&& (kind == POSE || distance >= 8.)
{
interval = interval.max(200);
}
if let Some(&(sent, seq)) = link.sent.get(&(id, kind)) {
if seq == latest.seq || now.saturating_sub(sent) < interval {
continue;
}
}
let keyframe = link
.keys
.get(&(id, kind))
.is_none_or(|at| now.saturating_sub(*at) >= 2000);
let baseline = link
.acks
.get(&(id, kind))
.and_then(|&seq| stream.at(seq).map(|s| (seq, s)))
.filter(|_| !keyframe);
let data =
packed::delta(self.session, id, kind, latest.seq, &latest.state, baseline);
if data.len() > packed::MTU {
self.stats.invalid += 1;
continue;
}
let bytes = data.len() as f64;
if link.credits < bytes || self.credits < bytes {
self.stats.budget_skips += 1;
continue;
}
link.credits -= bytes;
self.credits -= bytes;
link.sent.insert((id, kind), (now, latest.seq));
if baseline.is_none() {
link.keys.insert((id, kind), now);
}
output.push(Outgoing { peer, data });
}
}
}
self.stats.rtt_ms = self.links.values().map(|l| l.rtt).max().unwrap_or(0);
output
}
}
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
a.iter()
.zip(b)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f32>()
.sqrt()
}
fn write_info(b: &mut Vec<u8>, i: Info) {
for v in [i.id, i.map, i.rig, i.physics, i.appearance] {
b.extend(v.to_le_bytes());
}
}
fn read_info(r: &mut Reader) -> Option<Info> {
Some(Info {
id: r.u64()?,
map: r.u64()?,
rig: r.u64()?,
physics: r.u64()?,
appearance: r.u64()?,
})
}
+368
View File
@@ -0,0 +1,368 @@
//! Version 4: one datagram per independently decodable stream update.
//! Position error <= 0.5 mm/axis within 32 m; larger offsets use full floats.
//! Quaternion uses smallest-three 10-bit components. Velocities use binary16.
use crate::{Body, Bone, Pose};
use half::f16;
pub const MTU: usize = 1200;
pub const MAGIC: &[u8; 8] = b"SK8NET04";
pub const HEADER: usize = 29;
pub const BODY: u8 = 1;
pub const POSE: u8 = 2;
#[derive(Clone, Debug, PartialEq)]
pub struct Packed {
/// Source capture time in milliseconds; forwarding never retimestamps it.
pub captured: u64,
pub root: [u8; 16],
pub enabled: u64,
pub rows: Vec<Vec<u8>>,
}
#[derive(Clone, Debug)]
pub struct BodyState {
pub root: Pose,
pub enabled: u64,
pub bodies: Vec<Body>,
}
#[derive(Clone, Debug)]
pub struct PoseState {
pub root: Pose,
pub bones: Vec<Bone>,
}
pub fn header(session: u64, actor: u64, kind: u8, sequence: u32) -> Vec<u8> {
let mut b = Vec::with_capacity(MTU);
b.extend(MAGIC);
b.extend(session.to_le_bytes());
b.extend(actor.to_le_bytes());
b.push(kind);
b.extend(sequence.to_le_bytes());
b
}
pub fn envelope(b: &[u8]) -> Option<(u64, u64, u8, u32)> {
if b.len() < HEADER || b.len() > MTU || &b[..8] != MAGIC {
return None;
}
Some((
u64::from_le_bytes(b[8..16].try_into().ok()?),
u64::from_le_bytes(b[16..24].try_into().ok()?),
b[24],
u32::from_le_bytes(b[25..29].try_into().ok()?),
))
}
pub struct Reader<'a>(pub &'a [u8]);
impl Reader<'_> {
pub fn take<const N: usize>(&mut self) -> Option<[u8; N]> {
let b = self.0.get(..N)?.try_into().ok()?;
self.0 = &self.0[N..];
Some(b)
}
pub fn byte(&mut self) -> Option<u8> {
Some(self.take::<1>()?[0])
}
pub fn u32(&mut self) -> Option<u32> {
Some(u32::from_le_bytes(self.take()?))
}
pub fn u64(&mut self) -> Option<u64> {
Some(u64::from_le_bytes(self.take()?))
}
}
fn quat(q: [f32; 4]) -> [u8; 4] {
let largest = (0..4)
.max_by(|&a, &b| q[a].abs().total_cmp(&q[b].abs()))
.unwrap();
let sign = if q[largest] < 0. { -1. } else { 1. };
let mut bits = largest as u32;
let mut shift = 2;
for (i, v) in q.iter().enumerate() {
if i != largest {
let n = ((v * sign * std::f32::consts::SQRT_2 * 0.5 + 0.5) * 1023.)
.round()
.clamp(0., 1023.) as u32;
bits |= n << shift;
shift += 10;
}
}
bits.to_le_bytes()
}
fn unquat(b: [u8; 4]) -> [f32; 4] {
let bits = u32::from_le_bytes(b);
let largest = (bits & 3) as usize;
let mut q = [0.; 4];
let mut shift = 2;
let mut sum = 0.;
for (i, v) in q.iter_mut().enumerate() {
if i != largest {
*v = (((bits >> shift) & 1023) as f32 / 1023. - 0.5) * std::f32::consts::SQRT_2;
sum += *v * *v;
shift += 10;
}
}
q[largest] = (1. - sum).max(0.).sqrt();
let norm = q.iter().map(|v| v * v).sum::<f32>().sqrt();
q.map(|v| v / norm)
}
fn root(p: Pose) -> [u8; 16] {
let mut b = [0; 16];
for i in 0..3 {
b[i * 4..i * 4 + 4].copy_from_slice(&p.p[i].to_le_bytes());
}
b[12..].copy_from_slice(&quat(p.q));
b
}
fn unroot(b: [u8; 16]) -> Option<Pose> {
let p = Pose {
p: std::array::from_fn(|i| f32::from_le_bytes(b[i * 4..i * 4 + 4].try_into().unwrap())),
q: unquat(b[12..].try_into().unwrap()),
};
p.valid().then_some(p)
}
fn row_pose(b: &mut Vec<u8>, p: Pose, origin: [f32; 3]) {
let relative = std::array::from_fn::<_, 3, _>(|i| p.p[i] - origin[i]);
let small = relative.iter().all(|v| v.abs() <= 32.767);
b.push(u8::from(!small));
for v in relative {
if small {
b.extend(((v * 1000.).round() as i16).to_le_bytes());
} else {
b.extend(v.to_le_bytes());
}
}
b.extend(quat(p.q));
}
fn read_pose(r: &mut Reader, origin: [f32; 3]) -> Option<Pose> {
let wide = r.byte()?;
if wide > 1 {
return None;
}
let mut p = [0.; 3];
for i in 0..3 {
p[i] = origin[i]
+ if wide == 0 {
i16::from_le_bytes(r.take()?) as f32 / 1000.
} else {
f32::from_le_bytes(r.take()?)
};
}
let p = Pose {
p,
q: unquat(r.take()?),
};
p.valid().then_some(p)
}
fn rates(b: &mut Vec<u8>, v: [f32; 3]) {
for f in v {
b.extend(f16::from_f32(f).to_bits().to_le_bytes());
}
}
fn read_rates(r: &mut Reader, bound: f32) -> Option<[f32; 3]> {
let mut v = [0.; 3];
for f in &mut v {
*f = f16::from_bits(u16::from_le_bytes(r.take()?)).to_f32();
if !f.is_finite() || f.abs() > bound {
return None;
}
}
Some(v)
}
impl Packed {
pub fn same_state(&self, other: &Self) -> bool {
self.root == other.root && self.enabled == other.enabled && self.rows == other.rows
}
pub fn body(s: &BodyState) -> Option<Self> {
if !s.root.valid() || s.bodies.len() != 33 || (s.enabled & !(1 << 63)) >> 33 != 0 {
return None;
}
let mut rows = Vec::with_capacity(33);
for body in &s.bodies {
if !body.pose.valid()
|| !body
.velocity
.iter()
.all(|v| v.is_finite() && v.abs() <= 250.)
|| !body
.angular
.iter()
.all(|v| v.is_finite() && v.abs() <= 500.)
{
return None;
}
let mut row = Vec::with_capacity(29);
row_pose(&mut row, body.pose, s.root.p);
rates(&mut row, body.velocity);
rates(&mut row, body.angular);
rows.push(row);
}
Some(Self {
captured: 0,
root: root(s.root),
enabled: s.enabled,
rows,
})
}
pub fn pose(s: &PoseState) -> Option<Self> {
if !s.root.valid() || s.bones.len() > 32 {
return None;
}
let mut rows = Vec::with_capacity(s.bones.len());
let mut seen = vec![];
for bone in &s.bones {
if bone.index >= 256 || !bone.pose.valid() || seen.contains(&bone.index) {
return None;
}
seen.push(bone.index);
let mut row = vec![bone.index as u8];
row_pose(&mut row, bone.pose, [0.; 3]);
rows.push(row);
}
Some(Self {
captured: 0,
root: root(s.root),
enabled: 0,
rows,
})
}
pub fn unpack_body(&self) -> Option<BodyState> {
let root = unroot(self.root)?;
let mut bodies = vec![];
if self.rows.len() != 33 || (self.enabled & !(1 << 63)) >> 33 != 0 {
return None;
}
for row in &self.rows {
let mut r = Reader(row);
let body = Body {
pose: read_pose(&mut r, root.p)?,
velocity: read_rates(&mut r, 250.)?,
angular: read_rates(&mut r, 500.)?,
};
if !r.0.is_empty() {
return None;
}
bodies.push(body);
}
Some(BodyState {
root,
enabled: self.enabled,
bodies,
})
}
pub fn unpack_pose(&self) -> Option<PoseState> {
let root = unroot(self.root)?;
if self.rows.len() > 32 || self.enabled != 0 {
return None;
}
let mut bones = vec![];
for row in &self.rows {
let mut r = Reader(row);
let index = u16::from(r.byte()?);
if bones.iter().any(|b: &Bone| b.index == index) {
return None;
}
bones.push(Bone {
index,
pose: read_pose(&mut r, [0.; 3])?,
});
if !r.0.is_empty() {
return None;
}
}
Some(PoseState { root, bones })
}
pub fn position(&self) -> [f32; 3] {
unroot(self.root).map_or([0.; 3], |p| p.p)
}
}
pub fn delta(
session: u64,
actor: u64,
kind: u8,
seq: u32,
state: &Packed,
base: Option<(u32, &Packed)>,
) -> Vec<u8> {
let base = base.filter(|(_, b)| b.rows.len() == state.rows.len());
let mut out = header(session, actor, kind, seq);
out.extend(base.map_or(0, |b| b.0).to_le_bytes());
out.extend(state.captured.to_le_bytes());
out.extend(state.root);
out.extend(state.enabled.to_le_bytes());
out.push(state.rows.len() as u8);
let mask = state.rows.iter().enumerate().fold(0u64, |m, (i, row)| {
if base.is_none_or(|(_, b)| b.rows[i] != *row) {
m | (1 << i)
} else {
m
}
});
out.extend(mask.to_le_bytes());
for (i, row) in state.rows.iter().enumerate() {
if mask & (1 << i) != 0 {
out.extend(row);
}
}
debug_assert!(out.len() <= MTU);
out
}
pub fn baseline(data: &[u8]) -> Option<u32> {
u32::from_le_bytes(data.get(HEADER..HEADER + 4)?.try_into().ok()?).into()
}
pub fn apply(data: &[u8], base: Option<&Packed>) -> Option<Packed> {
let (_, _, kind, _) = envelope(data)?;
if kind != BODY && kind != POSE {
return None;
}
let mut r = Reader(&data[HEADER..]);
let base_seq = r.u32()?;
let captured = r.u64()?;
let root = r.take()?;
unroot(root)?;
let enabled = r.u64()?;
let count = r.byte()? as usize;
if (kind == BODY && count != 33) || (kind == POSE && count > 32) {
return None;
}
let mask = r.u64()?;
if mask >> count != 0 {
return None;
}
let mut rows = if base_seq != 0 {
let b = base?;
if b.rows.len() != count {
return None;
}
b.rows.clone()
} else {
if mask != (1u64 << count) - 1 {
return None;
}
vec![vec![]; count]
};
for (i, row) in rows.iter_mut().enumerate() {
if mask & (1 << i) == 0 {
continue;
}
let start = r.0;
if kind == POSE {
r.byte()?;
}
let wide = *r.0.first()?;
if wide > 1 {
return None;
}
let len = 1 + if wide == 0 { 6 } else { 12 } + 4 + if kind == BODY { 12 } else { 0 };
r.0 = r.0.get(len..)?;
*row = start[..start.len() - r.0.len()].to_vec();
}
if !r.0.is_empty() {
return None;
}
let result = Packed {
captured,
root,
enabled,
rows,
};
if kind == BODY {
result.unpack_body()?;
} else {
result.unpack_pose()?;
}
Some(result)
}
+269
View File
@@ -0,0 +1,269 @@
use skate_net::interpolation::{Buffer, Clock, position};
#[test]
fn independent_pose_samples_do_not_hold_on_intervening_physics_updates() {
let mut poses = Buffer::default();
poses.insert(0., 0f32);
poses.insert(0.1, 1f32);
// Physics at 50 ms must not insert another zero-valued pose here.
let (a, b, u) = poses.pair(0.075).unwrap();
let value = poses.samples[a].value * (1. - u) + poses.samples[b].value * u;
assert!((value - 0.75).abs() < 1e-6);
assert!(!poses.insert(0.1, 99.));
assert!(!poses.insert(0.05, 99.));
assert_eq!(poses.samples.len(), 2);
}
#[test]
fn root_curve_is_exact_at_samples_and_does_not_overshoot_an_impact() {
let mut roots = Buffer::default();
for (t, x) in [(0., 0.), (0.1, 1.), (0.2, 1.1), (0.3, 0.5)] {
roots.insert(t, [x, 0., 0.]);
}
for sample in &roots.samples {
assert_eq!(position(&roots, sample.time).unwrap(), sample.value);
}
for i in 100..200 {
let p = position(&roots, i as f64 / 1000.).unwrap();
assert!(p[0] >= 1. && p[0] <= 1.10001);
}
let mut linear = Buffer::default();
for i in 0..8 {
let t = i as f64 * 0.05;
linear.insert(t, [t as f32 * 3., 0., 0.]);
}
for i in 0..350 {
let t = i as f64 / 1000.;
assert!((position(&linear, t).unwrap()[0] - t as f32 * 3.).abs() < 1e-6);
}
}
fn simulate(fps: u32, loss: u64, jitter: bool) -> (f64, u64, f64) {
let mut clock = Clock::default();
let mut body = Buffer::default();
let mut pose = Buffer::default();
let mut packets = Vec::new();
let mut rng = 8741u64;
for i in 0..600 {
let source = i as f64 * 0.05;
for stream in 0..2 {
if stream == 1 && i % 2 != 0 {
continue;
}
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
if (rng >> 32) % 100 < loss {
continue;
}
let delay = if jitter {
0.03 + ((rng >> 40) % 61) as f64 / 1000.
} else {
0.04
};
packets.push((source + delay, stream, source));
}
}
packets.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut received = 0;
let mut previous = None;
let mut max_step = 0f64;
let mut last = 0.;
for frame in 0..fps * 29 {
let now = frame as f64 / fps as f64;
while received < packets.len() && packets[received].0 <= now {
let (at, stream, source) = packets[received];
received += 1;
let buffer = if stream == 0 { &mut body } else { &mut pose };
if buffer.insert(source, source as f32) {
clock.observe(stream, source, at);
}
}
if let Some(time) = clock.step(now) {
if let Some(prev) = previous {
assert!(time >= prev);
if now > 2. {
max_step = max_step.max(time - prev);
}
}
previous = Some(time);
last = time;
if now > 2. {
assert!(now - time < 0.8, "unbounded delay {fps}Hz: {}", now - time);
// Every rendered value is evaluated at source time, independent of arrival jitter.
for buffer in [&body, &pose] {
let (a, b, u) = buffer.pair(time).unwrap();
if a != b {
let value =
buffer.samples[a].value * (1. - u) + buffer.samples[b].value * u;
assert!((value - time as f32).abs() < 0.00001);
}
}
}
}
}
assert!(
max_step <= 1.081 / fps as f64,
"render discontinuity: {max_step}"
);
println!(
"{fps}fps loss={loss}% jitter={jitter}: delay={:.1}ms underruns={} max frame advance={:.3}ms",
clock.delay * 1000.,
clock.underruns,
max_step * 1000.
);
(last, clock.underruns, max_step)
}
#[test]
fn playback_is_smooth_at_60_120_144_fps_with_jitter_and_loss() {
for fps in [60, 120, 144] {
let (_, stalls, _) = simulate(fps, 0, false);
assert_eq!(stalls, 0);
let (_, stalls, _) = simulate(fps, 5, true);
assert!(stalls < 20, "excessive buffer starvation");
}
}
#[test]
fn suspended_render_loop_and_teleport_reset_do_not_replay_old_motion() {
let mut c = Clock::default();
c.observe(0, 1., 1.);
c.observe(1, 1., 1.);
c.step(1.);
c.observe(0, 10., 10.);
c.observe(1, 10., 10.);
assert!(c.step(10.).unwrap() > 7.);
c = Clock::default();
c.observe(0, 20., 20.);
c.observe(1, 20., 20.);
assert!(c.step(20.).unwrap() > 19.);
let mut b = Buffer::default();
for i in 0..100 {
b.insert(i as f64, i);
}
assert_eq!(b.samples.len(), 64);
}
#[test]
fn source_timestamps_survive_host_forwarding_and_do_not_disable_idle_detection() {
use skate_net::{
Body, Pose,
lobby::{Info, Session},
packed::{self, BodyState, Packed},
};
let info = |id| Info {
id,
map: 1,
rig: 2,
physics: 3,
appearance: 4,
};
let mut host = Session::new(8, info(1), None);
let mut sender = Session::new(8, info(2), Some(1));
let mut viewer = Session::new(8, info(3), Some(1));
for (id, guest) in [(2, &mut sender), (3, &mut viewer)] {
for p in guest.service(0) {
host.receive(id, &p.data, 1000);
}
}
for p in host.service(1000) {
if p.peer == 2 {
sender.receive(1, &p.data, 10);
} else {
viewer.receive(1, &p.data, 10);
}
}
let pose = Pose {
p: [0.; 3],
q: [0., 0., 0., 1.],
};
let state = Packed::body(&BodyState {
root: pose,
enabled: 0,
bodies: vec![
Body {
pose,
velocity: [0.; 3],
angular: [0.; 3]
};
33
],
})
.unwrap();
sender.publish(packed::BODY, state.clone(), 25);
for p in sender.service(25) {
host.receive(2, &p.data, 1050);
}
for p in host.service(1060) {
if p.peer == 3 {
viewer.receive(1, &p.data, 80);
}
}
let received = viewer.actors[&2].body.latest().unwrap();
assert_eq!(received.state.captured, 25);
assert_eq!(received.received, 80);
sender.publish(packed::BODY, state, 75);
assert_eq!(sender.actors[&2].body.changed, 25);
assert_eq!(sender.actors[&2].body.latest().unwrap().state.captured, 75);
}
#[test]
fn loopback_uses_a_short_buffer_and_recovers_after_sender_stalls() {
let mut clock = Clock::for_connection(true);
let mut last_source = -1.;
let mut final_age = 0.;
for frame in 0..1200 {
let now = frame as f64 / 120.;
let source = ((now - 0.006).max(0.) / 0.05).floor() * 0.05;
if !(4.0..5.0).contains(&now) && source > last_source {
clock.observe(0, source, now);
clock.observe(1, source, now);
last_source = source;
}
if let Some(t) = clock.step(now) {
if now > 1. && !(4.0..5.1).contains(&now) {
assert!(now - t < 0.18, "localhost backlog: {}", now - t);
}
final_age = now - t;
}
assert!(clock.delay <= 0.15);
}
println!(
"Loopback: target {:.1}ms, final visual age {:.1}ms",
clock.delay * 1000.,
final_age * 1000.
);
assert!(clock.delay < 0.08);
}
#[test]
fn head_offset_survives_packing_and_bind_pose_composition() {
use skate_core::animation::{output::Sqt, pose_add};
use skate_net::{
Bone, Pose,
packed::{self, Packed, PoseState},
};
let delta = Sqt {
scale: [1.; 4],
rotation: [0., 0., 0., 1.],
translation: [0., 0., 0., 1.],
};
let bind = Sqt {
translation: [0., 0.15, 0., 1.],
..delta
};
let composed = pose_add::add(delta, bind, true);
assert_eq!(composed.translation[1], 0.15);
let pose = |y| Pose {
p: [0., y, 0.],
q: [0., 0., 0., 1.],
};
let state = PoseState {
root: pose(0.),
bones: vec![
Bone {
index: 6,
pose: pose(1.55),
},
Bone {
index: 7,
pose: pose(1.55 + composed.translation[1]),
},
],
};
let packed = Packed::pose(&state).unwrap();
let data = packed::delta(1, 1, packed::POSE, 1, &packed, None);
let decoded = packed::apply(&data, None).unwrap().unpack_pose().unwrap();
assert!((decoded.bones[1].pose.p[1] - decoded.bones[0].pose.p[1] - 0.15).abs() < 0.001);
}
+124
View File
@@ -0,0 +1,124 @@
//! Synthetic solver test: no assets, renderer, game launch, or Steam initialization.
use skate_core::{
math::Vector3,
physics::{
assembly::{BoardHook, BodySnapshot},
board::{BODY_COUNT, BodyId},
board_step::{AttachedStep, BoardCollision, BoardStep, BoardStepSettings, CollisionBody},
collision::Sphere,
contact::RetailContactInput,
drive_frames::{
default_live_body_orientations, default_live_body_transforms, default_truck_transforms,
},
drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics},
force_queue::BoardForceQueue,
hook_drive::HookDriveState,
mass::default_skateboard_mass_properties,
rigid_body::{
RetailBodyRates, RetailSimulationStep, basis_from_quaternion, world_inverse_inertia,
},
world_contact::{ContactPrimitive, PrimitivePairSettings, primitive_pair_contacts},
},
};
#[test]
fn remote_proxy_after_skeleton_and_targets_receives_opposite_collision_response() {
let poses = default_live_body_transforms();
let rotations = default_live_body_orientations();
let masses = default_skateboard_mass_properties();
let mut board: [BodySnapshot; BODY_COUNT] = std::array::from_fn(|i| {
let basis = basis_from_quaternion(rotations[i]);
BodySnapshot {
state_flags: 4,
inertia: masses[i].dynamics,
rates: RetailBodyRates {
orientation: rotations[i],
basis,
world_inverse_inertia: world_inverse_inertia(
basis,
masses[i].dynamics.inverse_tensor,
),
position: poses[i].translation,
linear_velocity: Vector3::new(1., 0., 0.),
angular_velocity: Vector3::ZERO,
force_acceleration: Vector3::ZERO,
torque_acceleration: Vector3::ZERO,
kinetic_energy: 1.,
cool_down: 0,
},
}
});
let deck = BodyId::Deck.index();
let mut hook = BoardHook {
body: board[deck],
drive: HookDriveState::initial(),
};
hook.body.state_flags = 0;
let mut proxy = board[deck];
proxy.rates.position.x += 0.15;
proxy.rates.linear_velocity.x = -1.;
let last_remote = 30 + 9 * 33 - 1;
let mut attached = vec![proxy; last_remote + 1];
for b in &mut attached[..last_remote] {
b.state_flags = 0;
}
let manifold = primitive_pair_contacts(
ContactPrimitive::Sphere(Sphere {
center: board[deck].rates.position,
radius: 0.1,
}),
ContactPrimitive::Sphere(Sphere {
center: proxy.rates.position,
radius: 0.1,
}),
PrimitivePairSettings::skater_self_collision(),
)
.unwrap();
let pair = manifold.points[0];
let collision = BoardCollision {
body_a: CollisionBody::Board(BodyId::Deck),
body_b: CollisionBody::Attached(last_remote),
contact: RetailContactInput {
position_on_a: pair.a,
position_on_b: pair.b,
normal: manifold.normal,
restitution: 0.05,
static_friction: 0.5,
dynamic_friction: 0.4,
tag: 0,
},
};
BoardStep::default().advance_attached(
&mut board,
&mut hook,
&BoardForceQueue::default(),
&[collision],
[0.; 2],
BoardStepSettings {
simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO),
iterations: 25,
base_truck_transforms: default_truck_transforms(),
truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK),
force_point_y_offset: 0.,
},
AttachedStep {
bodies: attached.iter_mut().collect(),
contacts: &mut [],
joints: &mut [],
drives: &mut [],
},
);
assert!(
board[deck].rates.linear_velocity.x < 1.,
"Local board must react to the other player"
);
assert!(
attached[last_remote].rates.linear_velocity.x > -1.,
"Other player's dynamic proxy must receive opposite response"
);
assert!(
board
.iter()
.chain(&attached)
.all(|b| b.rates.position.x.is_finite())
);
}
+256
View File
@@ -0,0 +1,256 @@
use skate_net::{
Body, Bone, Pose,
lobby::{Info, Session},
packed::{self, BodyState, Packed, PoseState},
};
fn pose(p: [f32; 3], t: f32) -> Pose {
Pose {
p,
q: [0., (t / 2.).sin(), 0., (t / 2.).cos()],
}
}
fn body() -> BodyState {
BodyState {
root: pose([330., 20., 100.], 0.),
enabled: ((1 << 33) - 1) | (1 << 63),
bodies: (0..33)
.map(|i| Body {
pose: pose(
[330. + i as f32 * 0.04567, 20.1234, 100.2345],
i as f32 * 0.23,
),
velocity: [12.345, 0., -31.123],
angular: [0.123, 24.789, 0.],
})
.collect(),
}
}
fn info(id: u64) -> Info {
Info {
id,
map: 1,
rig: 2,
physics: 3,
appearance: 4,
}
}
fn exchange(host: &mut Session, guest: &mut Session, endpoint: u64, time: u64) {
for p in guest.service(time) {
host.receive(endpoint, &p.data, time);
}
for p in host.service(time) {
if p.peer == endpoint {
guest.receive(1, &p.data, time);
}
}
}
#[test]
fn compact_roundtrip_and_delta_survive_a_lost_update() {
let original = body();
let a = Packed::body(&original).unwrap();
let wire = packed::delta(1, 2, packed::BODY, 1, &a, None);
assert_eq!(wire.len(), 833);
let decoded = packed::apply(&wire, None).unwrap();
for (a, b) in original
.bodies
.iter()
.zip(decoded.unpack_body().unwrap().bodies)
{
for i in 0..3 {
assert!((a.pose.p[i] - b.pose.p[i]).abs() < 0.00055);
assert!((a.velocity[i] - b.velocity[i]).abs() < 0.02);
}
let dot = a
.pose
.q
.iter()
.zip(b.pose.q)
.map(|(a, b)| a * b)
.sum::<f32>()
.abs()
.min(1.);
assert!(2. * dot.acos() < 0.006);
}
let mut moved = original.clone();
moved.bodies[0].pose.p[0] += 0.02;
let b = Packed::body(&moved).unwrap();
let delta = packed::delta(1, 2, packed::BODY, 3, &b, Some((1, &a)));
assert!(delta.len() < 110);
assert_eq!(packed::apply(&delta, Some(&decoded)), Some(b));
assert!(packed::apply(&delta, None).is_none());
for n in 0..wire.len() {
assert!(packed::apply(&wire[..n], None).is_none());
}
}
#[test]
fn maximum_offsets_and_pose_fit_one_datagram() {
let mut b = body();
for body in &mut b.bodies {
body.pose.p[0] += 1000.;
}
let p = Packed::body(&b).unwrap();
let wire = packed::delta(1, 2, packed::BODY, 1, &p, None);
assert_eq!(wire.len(), 1031);
assert!(packed::apply(&wire, None).is_some());
let state = PoseState {
root: b.root,
bones: (0..32)
.map(|i| Bone {
index: i,
pose: pose([1000.; 3], i as f32),
})
.collect(),
};
let p = Packed::pose(&state).unwrap();
let wire = packed::delta(1, 2, packed::POSE, 1, &p, None);
assert!(wire.len() <= packed::MTU);
assert!(packed::apply(&wire, None).is_some());
b.bodies[0].velocity[0] = f32::NAN;
assert!(Packed::body(&b).is_none());
}
#[test]
fn admission_full_lobby_mismatch_departure_and_rejoin() {
let mut host = Session::new(1, info(1), None);
for id in 2..=10 {
let mut guest = Session::new(1, info(id), Some(1));
exchange(&mut host, &mut guest, id, id * 100);
assert!(guest.connected());
}
assert_eq!(host.actors.len(), 10);
let mut extra = Session::new(1, info(11), Some(1));
exchange(&mut host, &mut extra, 11, 1100);
assert!(extra.notice.contains("full"));
assert!(!extra.connected());
let mut bad = info(12);
bad.map = 88;
let mut guest = Session::new(1, bad, Some(1));
exchange(&mut host, &mut guest, 12, 1200);
assert!(guest.notice.contains("Map"));
bad.map = 1;
bad.physics = 99;
let mut guest = Session::new(1, bad, Some(1));
exchange(&mut host, &mut guest, 12, 1300);
assert!(guest.notice.contains("Physics"));
host.receive(2, &packed::header(1, 2, skate_net::lobby::GOODBYE, 0), 1400);
assert_eq!(host.actors.len(), 9);
exchange(&mut host, &mut extra, 11, 1700);
assert!(extra.connected());
assert_eq!(host.actors.len(), 10);
let mut restarted = Session::new(1, info(22), Some(1));
exchange(&mut host, &mut restarted, 3, 1800);
assert!(!host.actors.contains_key(&3));
assert!(host.actors.contains_key(&22));
}
#[test]
fn guests_cannot_spoof_other_actors_and_timeouts_remove_proxies() {
let mut host = Session::new(1, info(1), None);
let mut guest = Session::new(1, info(2), Some(1));
exchange(&mut host, &mut guest, 2, 100);
let p = Packed::body(&body()).unwrap();
host.receive(2, &packed::delta(1, 1, packed::BODY, 1, &p, None), 110);
assert!(host.actors[&1].body.latest().is_none());
host.receive(999, &packed::delta(1, 2, packed::BODY, 1, &p, None), 120);
assert!(host.actors[&2].body.latest().is_none());
host.service(6000);
assert_eq!(host.actors.len(), 1);
guest.service(6000);
assert_eq!(guest.actors.len(), 1);
assert!(!guest.connected());
}
#[test]
fn ten_sessions_exchange_production_packets_over_real_loopback_udp() {
use std::{net::UdpSocket, time::Duration};
let sockets: Vec<_> = (0..10)
.map(|_| {
let s = UdpSocket::bind("127.0.0.1:0").unwrap();
s.set_nonblocking(true).unwrap();
s
})
.collect();
let addresses: Vec<_> = sockets.iter().map(|s| s.local_addr().unwrap()).collect();
let mut sessions: Vec<_> = (0..10)
.map(|i| Session::new(9, info(i + 1), (i != 0).then_some(1)))
.collect();
let packed = Packed::body(&body()).unwrap();
let mut buf = [0; 1500];
for time in (0..2500).step_by(10) {
for (i, s) in sessions.iter_mut().enumerate() {
while let Ok((n, from)) = sockets[i].recv_from(&mut buf) {
let peer = addresses.iter().position(|&a| a == from).unwrap() as u64 + 1;
s.receive(peer, &buf[..n], time);
}
if time % 50 == 0 {
s.publish(packed::BODY, packed.clone(), time);
}
for p in s.service(time) {
sockets[i]
.send_to(&p.data, addresses[p.peer as usize - 1])
.unwrap();
}
}
std::thread::sleep(Duration::from_millis(1));
}
for s in &sessions {
assert_eq!(s.actors.len(), 10);
assert!(s.actors.values().all(|a| a.body.latest().is_some()));
assert_eq!(s.stats.invalid + s.stats.baseline_miss, 0);
}
}
#[test]
fn repeated_host_migration_preserves_local_state_and_rebuilds_ten_player_lobby() {
let mut sessions: Vec<_> = (1..=10)
.map(|id| Session::new(77, info(id), if id == 1 { None } else { Some(1) }))
.collect();
let state = Packed::body(&body()).unwrap();
for s in &mut sessions {
s.publish(packed::BODY, state.clone(), 50);
}
fn pump(sessions: &mut [Session], first: usize, begin: u64) {
for now in (begin..begin + 2000).step_by(10) {
let mut packets = Vec::new();
for (i, s) in sessions.iter_mut().enumerate().skip(first) {
for p in s.service(now) {
packets.push((i as u64 + 1, p));
}
}
for (from, p) in packets {
if p.peer as usize > first {
sessions[p.peer as usize - 1].receive(from, &p.data, now);
}
}
}
}
pump(&mut sessions, 0, 100);
assert!(sessions.iter().all(|s| s.actors.len() == 10));
for first in 1..=2 {
let now = 2200 * first as u64;
for (i, s) in sessions.iter_mut().enumerate().skip(first) {
let before = s.actors[&s.local].body.latest().unwrap().clone();
s.migrate(
if i == first {
None
} else {
Some(first as u64 + 1)
},
now,
);
let after = s.actors[&s.local].body.latest().unwrap();
assert_eq!(before.seq, after.seq);
assert_eq!(before.state, after.state);
assert_eq!(s.actors.len(), 1);
}
// Late packets from the departed host must not restore its membership.
let stale = packed::delta(77, 1, packed::BODY, 100, &state, None);
for s in sessions.iter_mut().skip(first) {
s.receive(1, &stale, now);
}
pump(&mut sessions, first, now);
for s in sessions.iter().skip(first) {
assert!(s.connected());
assert_eq!(s.actors.len(), 10 - first);
assert!(s.actors.values().all(|a| a.body.latest().is_some()));
assert!(!s.actors.contains_key(&1));
}
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "skate-steam-relay"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "skate-steam-relay"
path = "src/main.rs"
[dependencies]
steamworks = "=0.13.1"
skate-net = { path = "../skate-net" }
+244
View File
@@ -0,0 +1,244 @@
use skate_net::directory::{self, Command, Event, Request, Response, Row};
use std::{
collections::BTreeSet,
sync::mpsc::{self, Receiver, Sender},
time::{Duration, Instant},
};
use steamworks::{
Client, DistanceFilter, LobbyId, LobbyKey, LobbyType, Matchmaking, StringFilter,
StringFilterKind,
};
enum ResultEvent {
List(u64, usize, u64, u64, Result<Vec<LobbyId>, String>),
Host(u64, u64, u64, String, Result<LobbyId, String>),
Join(u64, u64, u64, Result<LobbyId, String>),
}
pub struct Directory {
pub lobby: Option<LobbyId>,
pub owner: u64,
pub members: BTreeSet<u64>,
tx: Sender<ResultEvent>,
rx: Receiver<ResultEvent>,
request: Option<u64>,
pending: bool,
began: Instant,
cached: Option<Response>,
last_owner: Instant,
}
impl Directory {
pub fn new() -> Self {
let (tx, rx) = mpsc::channel();
Self {
lobby: None,
owner: 0,
members: BTreeSet::new(),
tx,
rx,
request: None,
pending: false,
began: Instant::now(),
cached: None,
last_owner: Instant::now() - Duration::from_secs(1),
}
}
pub fn leave(&mut self, mm: &Matchmaking) {
if let Some(l) = self.lobby.take() {
mm.leave_lobby(l);
}
self.owner = 0;
self.members.clear();
}
fn finish(&mut self, id: u64, event: Event) -> Response {
let r = Response { request: id, event };
self.pending = false;
self.cached = Some(r.clone());
r
}
pub fn command(&mut self, r: Request, client: &Client) -> Option<Response> {
if self.request == Some(r.id) {
return self.cached.clone();
}
if self.pending {
return Some(Response {
request: r.id,
event: Event::Error("Steam request already in progress".into()),
});
}
self.request = Some(r.id);
self.pending = true;
self.cached = None;
self.began = Instant::now();
let mm = client.matchmaking();
let tx = self.tx.clone();
let id = r.id;
match r.command {
Command::Browse { page, map, physics } => {
mm.add_request_lobby_list_string_filter(StringFilter(
LobbyKey::new("sk8game"),
directory::NAMESPACE,
StringFilterKind::Equal,
));
mm.set_request_lobby_list_distance_filter(DistanceFilter::Worldwide);
mm.request_lobby_list(move |result| {
let _ = tx.send(ResultEvent::List(
id,
page,
map,
physics,
result.map_err(|e| e.to_string()),
));
});
}
Command::Host {
map,
physics,
label,
} => {
self.leave(&mm);
mm.create_lobby(LobbyType::Public, 10, move |result| {
let _ = tx.send(ResultEvent::Host(
id,
map,
physics,
label,
result.map_err(|e| e.to_string()),
));
});
}
Command::Join {
lobby,
map,
physics,
} => {
self.leave(&mm);
mm.join_lobby(LobbyId::from_raw(lobby), move |result| {
let _ = tx.send(ResultEvent::Join(
id,
map,
physics,
result
.map_err(|_| "Lobby unavailable, full, or Steam could not join".into()),
));
});
}
};
None
}
fn compatible(mm: &Matchmaking, l: LobbyId, map: u64, physics: u64) -> bool {
mm.lobby_data(l, "sk8game").as_deref() == Some(directory::NAMESPACE)
&& mm.lobby_data(l, "map") == Some(map.to_string())
&& mm.lobby_data(l, "physics") == Some(physics.to_string())
}
fn owner_event(&mut self, mm: &Matchmaking, own: u64) -> Option<Event> {
let l = self.lobby?;
let owner = mm.lobby_owner(l).raw();
self.members = mm.lobby_members(l).iter().map(|i| i.raw()).collect();
if owner == 0 || !self.members.contains(&own) {
return None;
}
self.owner = owner;
Some(Event::Owner {
lobby: l.raw(),
owner,
own,
})
}
pub fn poll(&mut self, client: &Client) -> Vec<Response> {
let mm = client.matchmaking();
let own = client.user().steam_id().raw();
let mut output = Vec::new();
while let Ok(result) = self.rx.try_recv() {
let id = match &result {
ResultEvent::List(id, ..)
| ResultEvent::Host(id, ..)
| ResultEvent::Join(id, ..) => *id,
};
if self.request != Some(id) || !self.pending {
match result {
ResultEvent::Host(_, _, _, _, Ok(l)) | ResultEvent::Join(_, _, _, Ok(l)) => {
mm.leave_lobby(l)
}
_ => (),
};
continue;
}
let event = match result {
ResultEvent::List(_, page, map, physics, result) => match result {
Err(e) => Event::Error(e),
Ok(mut list) => {
list.retain(|&l| {
mm.lobby_data(l, "sk8game").as_deref() == Some(directory::NAMESPACE)
});
list.sort_by_key(LobbyId::raw);
let total = list.len();
let page = page.min(total.saturating_sub(1) / directory::PAGE_SIZE);
let rows = list
.into_iter()
.skip(page * directory::PAGE_SIZE)
.take(directory::PAGE_SIZE)
.map(|l| Row {
id: l.raw(),
map: clean(
&mm.lobby_data(l, "label")
.unwrap_or_else(|| "Unknown map".into()),
),
players: mm.lobby_member_count(l).min(10),
capacity: mm.lobby_member_limit(l).unwrap_or(10).min(10),
compatible: Self::compatible(&mm, l, map, physics),
})
.collect();
Event::Rows { page, total, rows }
}
},
ResultEvent::Host(_, map, physics, label, result) => match result {
Err(e) => Event::Error(e),
Ok(l) => {
let okay = mm.set_lobby_data(l, "map", &map.to_string())
&& mm.set_lobby_data(l, "physics", &physics.to_string())
&& mm.set_lobby_data(l, "label", &clean(&label))
&& mm.set_lobby_data(l, "sk8game", directory::NAMESPACE);
if !okay {
mm.leave_lobby(l);
Event::Error("Steam could not publish lobby details".into())
} else {
self.lobby = Some(l);
self.owner_event(&mm, own)
.unwrap_or(Event::Error("Steam lobby owner unavailable".into()))
}
}
},
ResultEvent::Join(_, map, physics, result) => match result {
Err(e) => Event::Error(e),
Ok(l) => {
if !Self::compatible(&mm, l, map, physics) {
mm.leave_lobby(l);
Event::Error("Different map, physics, or game version. Load the matching map/build first.".into())
} else {
self.lobby = Some(l);
self.owner_event(&mm, own)
.unwrap_or(Event::Error("Steam lobby owner unavailable".into()))
}
}
},
};
output.push(self.finish(id, event));
}
if self.pending && self.began.elapsed() > Duration::from_secs(15) {
output.push(self.finish(
self.request.unwrap(),
Event::Error("Steam request timed out; refresh or retry".into()),
));
}
// Repeated notifications survive loopback datagram loss and track Steam owner election.
if self.last_owner.elapsed() > Duration::from_millis(250) {
if let Some(event) = self.owner_event(&mm, own) {
output.push(Response { request: 0, event });
}
self.last_owner = Instant::now();
}
output
}
}
fn clean(text: &str) -> String {
directory::label(text)
}
+254
View File
@@ -0,0 +1,254 @@
//! Optional Steam byte relay. The game never links this SDK or calls Steam Input.
mod directory;
use std::{
collections::BTreeMap,
net::{SocketAddr, UdpSocket},
time::{Duration, Instant},
};
use steamworks::{
Client, SteamId,
networking_types::{NetworkingIdentity, SendFlags},
};
const CHANNEL: u32 = 38;
fn main() {
if let Err(e) = run() {
eprintln!("Steam relay: {e}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let args: Vec<_> = std::env::args().skip(1).collect();
if args.len() != 4 {
return Err(
"Expected parent-address, peer Steam ID (0 to host), session, IPC cookie".into(),
);
}
let parent: SocketAddr = args[0].parse().map_err(|_| "Invalid parent")?;
if !parent.ip().is_loopback() {
return Err("IPC must be loopback".into());
}
let mut peer = args[1].parse::<u64>().map_err(|_| "Invalid peer")?;
let mut session = args[2].parse::<u64>().map_err(|_| "Invalid session")?;
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())?;
let status = |s: &str| {
let _ = socket.send(format!("SK8RELAY {cookie} {s}").as_bytes());
};
status("Starting Steam");
let client = match Client::init_app(480) {
Ok(c) => c,
Err(e) => {
status(&format!("ERROR Open Steam and sign in, then retry. {e}"));
return Err(e.to_string());
}
};
let own = client.user().steam_id().raw();
if peer == own {
status(
"ERROR Two Steam players need different Steam accounts. Use local testing on one PC.",
);
return Err("Same Steam identity".into());
}
client.networking_utils().init_relay_network_access();
let messages = client.networking_messages();
let mut attempts = 0;
let mut window = Instant::now();
messages.session_request_callback(move |request| {
if window.elapsed() > Duration::from_secs(10) {
attempts = 0;
window = Instant::now();
}
let id = request.remote().steam_id().map(|id| id.raw()).unwrap_or(0);
if id != 0 && id != own && attempts < 20 {
attempts += 1;
request.accept();
}
});
// Game admission owns the ten-player limit; this adapter only routes bytes.
let mut peers: BTreeMap<u64, (Instant, i64)> = BTreeMap::new();
if peer != 0 {
peers.insert(peer, (Instant::now(), 0));
}
let cookie_value = u64::from_str_radix(cookie, 16).map_err(|_| "Invalid cookie")?;
let ping = format!("PING {cookie}");
let mut dropped = 0u64;
let mut failures = 0u64;
let mut heartbeat = Instant::now() - Duration::from_secs(2);
let mut parent_seen = Instant::now();
let mut buffer = [0u8; 1500];
let mut directory = directory::Directory::new();
let mut route = None;
'running: loop {
client.run_callbacks();
for response in directory.poll(&client) {
status(&format!(
"EVENT {}",
skate_net::directory::encode(&response)
));
}
if let Some(lobby) = directory.lobby {
let next = (lobby.raw(), directory.owner);
if directory.owner != 0 && route != Some(next) {
session = lobby.raw();
peer = if directory.owner == own {
0
} else {
directory.owner
};
peers.clear();
if peer != 0 {
peers.insert(peer, (Instant::now(), 0));
}
route = Some(next);
}
}
if heartbeat.elapsed() > Duration::from_secs(1) {
status(&format!("READY {own}"));
let mut up = 0f32;
let mut down = 0f32;
let mut queue = 0i64;
let mut loss = 0f32;
let mut ping_ms = 0;
// NetworkingMessages expires idle sessions; bound our routing/metrics cache too.
peers.retain(|id, (seen, _)| seen.elapsed() <= Duration::from_secs(10) || *id == peer);
for (&id, (_, queued)) in &mut peers {
let (_, _, realtime) = messages.get_session_connection_info(
&NetworkingIdentity::new_steam_id(SteamId::from_raw(id)),
);
if let Some(r) = realtime {
up += r.out_bytes_per_sec();
down += r.in_bytes_per_sec();
ping_ms = ping_ms.max(r.ping());
// steamworks-rs calls this queued_send_bytes, but the SDK field is m_usecQueueTime.
*queued = r.queued_send_bytes().max(0) / 1000;
queue = queue.max(*queued);
for quality in [r.connection_quality_local(), r.connection_quality_remote()] {
if quality >= 0. {
loss = loss.max((1. - quality) * 100.);
}
}
}
}
status(&format!(
"STATS {} Steam up {:.1} / down {:.1} kB/s | ping {} ms | loss {:.1}% | queue {} ms | drops {} | errors {}",
u8::from(queue > 20),
up / 1000.,
down / 1000.,
ping_ms,
loss,
queue,
dropped,
failures
));
heartbeat = Instant::now();
}
for _ in 0..256 {
match socket.recv(&mut buffer) {
Ok(n) => {
if buffer[..n] == *ping.as_bytes() {
parent_seen = Instant::now();
continue;
}
if buffer[..n] == *format!("QUIT {cookie}").as_bytes() {
directory.leave(&client.matchmaking());
break 'running;
}
if let Ok(text) = std::str::from_utf8(&buffer[..n]) {
if let Some(json) = text.strip_prefix(&format!("CMD {cookie} ")) {
if let Some(request) = skate_net::directory::request(json) {
parent_seen = Instant::now();
if let Some(response) = directory.command(request, &client) {
status(&format!(
"EVENT {}",
skate_net::directory::encode(&response)
));
}
}
continue;
}
}
if n < 16 || u64::from_le_bytes(buffer[..8].try_into().unwrap()) != cookie_value
{
continue;
}
let target = u64::from_le_bytes(buffer[8..16].try_into().unwrap());
let data = &buffer[16..n];
let Some((s, _, kind, _)) = skate_net::packed::envelope(data) else {
continue;
};
if s != session
|| target == 0
|| (peer != 0 && peer != target)
|| (directory.lobby.is_some() && !directory.members.contains(&target))
{
continue;
}
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) {
dropped += 1;
continue;
}
let flags = if state {
SendFlags::UNRELIABLE_NO_DELAY
} else {
SendFlags::UNRELIABLE_NO_NAGLE
};
if let Err(e) = messages.send_message_to_user(
NetworkingIdentity::new_steam_id(SteamId::from_raw(target)),
flags,
data,
CHANNEL,
) {
if matches!(e, steamworks::SteamError::Ignored) {
dropped += 1;
} else {
failures += 1;
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(e) => return Err(e.to_string()),
}
}
for message in messages.receive_messages_on_channel(CHANNEL, 256) {
let id = message
.identity_peer()
.steam_id()
.map(|id| id.raw())
.unwrap_or(0);
let Some((s, _, kind, _)) = skate_net::packed::envelope(message.data()) else {
continue;
};
if s != session
|| id == 0
|| id == own
|| (peer != 0 && id != peer)
|| (directory.lobby.is_some() && !directory.members.contains(&id))
{
continue;
}
if !peers.contains_key(&id) {
if kind != skate_net::lobby::HELLO || peers.len() >= 12 {
continue;
}
peers.insert(id, (Instant::now(), 0));
}
peers.get_mut(&id).unwrap().0 = Instant::now();
let mut packet = cookie_value.to_le_bytes().to_vec();
packet.extend(id.to_le_bytes());
packet.extend(message.data());
if socket.send(&packet).is_err() {
failures += 1;
}
}
if parent_seen.elapsed() > Duration::from_secs(15) {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
directory.leave(&client.matchmaking());
Ok(())
}