diff --git a/BUILD-MULTIPLAYER-TEST.bat b/BUILD-MULTIPLAYER-TEST.bat new file mode 100644 index 00000000..6b93f068 --- /dev/null +++ b/BUILD-MULTIPLAYER-TEST.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build-multiplayer-test.ps1" +if errorlevel 1 pause diff --git a/Cargo.lock b/Cargo.lock index 20f03a22..b458bc22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4383,6 +4383,25 @@ dependencies = [ "serde_json", "skate-core", "skate-data", + "skate-net", +] + +[[package]] +name = "skate-net" +version = "0.1.0" +dependencies = [ + "half", + "serde", + "serde_json", + "skate-core", +] + +[[package]] +name = "skate-steam-relay" +version = "0.1.0" +dependencies = [ + "skate-net", + "steamworks", ] [[package]] @@ -4499,6 +4518,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "steamworks" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ff29921f6a7e8c85b0c971ec3c42002b9f535e3b8f4358cb22911d43709739a" +dependencies = [ + "bitflags 2.13.1", + "paste", + "steamworks-sys", + "thiserror 2.0.20", +] + +[[package]] +name = "steamworks-sys" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae139f051204a4af015d4f0ed3a1f7a51020d03a44c7cf249168c543d88131e9" + [[package]] name = "strict-num" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 36cacc18..14c18769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/skate-core", "crates/skate-data", "crates/skate-game"] +members = ["crates/skate-core", "crates/skate-data", "crates/skate-game", "crates/skate-net", "crates/skate-steam-relay"] resolver = "3" [workspace.package] diff --git a/PLAY-MULTIPLAYER-10-INSTANCES.bat b/PLAY-MULTIPLAYER-10-INSTANCES.bat new file mode 100644 index 00000000..b95195fa --- /dev/null +++ b/PLAY-MULTIPLAYER-10-INSTANCES.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\launch-multiplayer-test.ps1" -Players 10 %* +if errorlevel 1 pause diff --git a/PLAY-MULTIPLAYER-2-INSTANCES.bat b/PLAY-MULTIPLAYER-2-INSTANCES.bat new file mode 100644 index 00000000..981bad2a --- /dev/null +++ b/PLAY-MULTIPLAYER-2-INSTANCES.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\launch-multiplayer-test.ps1" %* +if errorlevel 1 pause diff --git a/PLAY-MULTIPLAYER-MENU.bat b/PLAY-MULTIPLAYER-MENU.bat new file mode 100644 index 00000000..82fb5877 --- /dev/null +++ b/PLAY-MULTIPLAYER-MENU.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\launch-multiplayer-test.ps1" -BuildDirectory bin/multiplayer-lobbies -MenuOnly %* +if errorlevel 1 pause diff --git a/PLAY-MULTIPLAYER-SMOOTH-2-INSTANCES.bat b/PLAY-MULTIPLAYER-SMOOTH-2-INSTANCES.bat new file mode 100644 index 00000000..dadb8cd1 --- /dev/null +++ b/PLAY-MULTIPLAYER-SMOOTH-2-INSTANCES.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\launch-multiplayer-test.ps1" -BuildDirectory bin/multiplayer-lobbies %* +if errorlevel 1 pause diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index 60e4b20e..45b28a88 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -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" } diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 7f970160..5531043a 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -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)) @@ -105,5 +106,6 @@ pub(crate) fn build( verification::VerificationPlugin, crate::performance::PerformancePlugin, )); + app.add_plugins(crate::multiplayer::MultiplayerPlugin); app } diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs index 3c56c2a6..09e1ef64 100644 --- a/crates/skate-game/src/config.rs +++ b/crates/skate-game/src/config.rs @@ -9,6 +9,8 @@ pub(crate) struct Config { pub map_path: Option, pub difficulty: crate::difficulty::Difficulty, pub check_assets: bool, + pub multiplayer: crate::multiplayer::Options, + pub map_fingerprint: u64, } impl Config { @@ -20,12 +22,37 @@ impl Config { map_path: None, difficulty: crate::difficulty::Difficulty::default(), check_assets: 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() } @@ -69,6 +96,15 @@ impl Config { config.map_path = Some(path.canonicalize().map_err(|e| e.to_string())?); } } + config.map_fingerprint = if let Some(path)=&config.map_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")}; + 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()); diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index a3d77f99..4377b1f8 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -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, selected_map: usize, + multiplayer: bool, + browser: bool, } pub(crate) fn gameplay_active(menu: Option>) -> bool { menu.is_none_or(|m| !m.open) @@ -196,9 +198,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..10 { + for i in 0..11 { 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))); @@ -210,7 +212,10 @@ fn setup( }); commands.insert_resource(SceneTarget(target)); let maps = crate::map_library::discover(&config.asset_root); - let selected_map = maps.iter().position(|m| m.path.as_ref() == config.map_path.as_ref()).unwrap_or(0); + let selected_map = maps + .iter() + .position(|m| m.path.as_ref() == config.map_path.as_ref()) + .unwrap_or(0); commands.insert_resource(Menu { open: false, selected: 0, @@ -221,6 +226,8 @@ fn setup( status: String::new(), maps, selected_map, + multiplayer: false, + browser: false, }); } fn msaa(samples: u32) -> Msaa { @@ -243,17 +250,39 @@ fn interact( mut time: ResMut>, buttons: Query<(&Interaction, &MenuRow), Changed>, mut exit: MessageWriter, + mut net: ResMut, + mut typing: MessageReader, ) { if keys.just_pressed(KeyCode::Escape) { 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 { if keys.just_pressed(KeyCode::ArrowUp) { - menu.selected = (menu.selected + 9) % 10; + menu.selected = (menu.selected + 10) % 11; } if keys.just_pressed(KeyCode::ArrowDown) { - menu.selected = (menu.selected + 1) % 10; + menu.selected = (menu.selected + 1) % 11; } if keys.just_pressed(KeyCode::ArrowLeft) { action = Some((menu.selected, -1)); @@ -269,48 +298,111 @@ 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; - } - 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); - 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 => { - match crate::map_library::switch(&config.asset_root, &menu.maps[menu.selected_map]) { - Ok(()) => { exit.write(AppExit::Success); } - Err(e) => menu.status = e, + 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; + } + _ => {} } - 8 => menu.open = false, - 9 => { - exit.write(AppExit::Success); + } 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 = 10; + } + _ => {} + } + } 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); + 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 { + match crate::map_library::switch( + &config.asset_root, + &menu.maps[menu.selected_map], + ) { + Ok(()) => { + exit.write(AppExit::Success); + } + Err(e) => menu.status = e, + } + } + } + 8 => menu.open = false, + 9 => { + exit.write(AppExit::Success); + } + 10 => { + menu.multiplayer = true; + menu.selected = 0; + } + _ => {} } - _ => {} } - 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( @@ -325,7 +417,7 @@ fn interact( }; } } - if menu.open { + if menu.open && !net.active() { time.pause(); } else { time.unpause(); @@ -356,12 +448,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); @@ -380,6 +479,7 @@ fn apply( } fn labels( menu: Res, + net: Res, window: Single<&Window, With>, mut root: Single<&mut Node, With>, mut labels: Query<(&MenuLabel, &mut Text), Without>, @@ -397,37 +497,105 @@ 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 => "Load map (restarts session)".into(), - 8 => "Resume".into(), - _ => "Quit game".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 => "Load map (restarts session)".into(), + 8 => "Resume".into(), + 9 => "Quit game".into(), + _ => "Multiplayer".into(), + } }; } - ***status = menu.status.clone(); + ***status = 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) in &mut buttons { color.0 = if row.0 == menu.selected || *interaction == Interaction::Hovered { Color::srgb(0.10, 0.30, 0.34) @@ -456,12 +624,22 @@ mod tests { fn culling_can_toggle_with_msaa_and_render_scale_changes() { let mut app = App::new(); let mut images = Assets::::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(), + open: false, + selected: 0, + settings: GraphicsSettings::default(), + difficulty: Difficulty::Easy, + path: PathBuf::new(), + supported_msaa: vec![1, 2, 4, 8], + status: String::new(), }) .add_systems(Update, apply); app.world_mut().spawn((Window::default(), PrimaryWindow)); @@ -479,7 +657,14 @@ mod tests { assert!(!app.world().entity(camera).contains::()); assert!(!app.world().entity(camera).contains::()); assert_eq!(*app.world().get::(camera).unwrap(), Msaa::Off); - assert_eq!(app.world().resource::>().get(&target).unwrap().size(), UVec2::new(857, 536)); + assert_eq!( + app.world() + .resource::>() + .get(&target) + .unwrap() + .size(), + UVec2::new(857, 536) + ); { let mut menu = app.world_mut().resource_mut::(); menu.settings.occlusion = true; diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 4ab9ad4d..71ebf849 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -34,9 +34,15 @@ impl Plugin for InputPlugin { } } -pub(crate) fn poll_controllers(mut input: ResMut) { +pub(crate) fn poll_controllers(mut input: ResMut,config:Res,net:Option>,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) { fn publish_actions( mut input: ResMut, mut published: ResMut, + menu:Option>, ) { + if !crate::graphics_menu::gameplay_active(menu) {input.discard_gameplay();} input.publish_actions(); published.0 = input.tick_input(); } diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 8d93deda..4522a4a5 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -1,4 +1,5 @@ mod animation; +mod multiplayer; mod animation_pose; mod app; mod assets; @@ -56,13 +57,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) => { diff --git a/crates/skate-game/src/multiplayer.rs b/crates/skate-game/src/multiplayer.rs new file mode 100644 index 00000000..be600b1f --- /dev/null +++ b/crates/skate-game/src/multiplayer.rs @@ -0,0 +1,629 @@ +//! 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, + pub session: u64, + pub spawn_offset: f32, + pub appearance: Option, + pub title: Option, + pub controller: Option, +} +struct VisualRoot { + captured: f64, + received: f64, + pose: skate_net::Pose, +} +struct VisualPose { + captured: f64, + received: f64, + bones: Vec, +} +struct Remote { + roots: VecDeque, + poses: VecDeque, + epoch: u64, + visual_since: u64, + body: BodyState, + body_at: Instant, + body_seq: u32, + pose_seq: u32, +} +#[derive(Resource)] +pub(crate) struct Multiplayer { + transport: Option>, + lobby: Option, + info: Info, + schema: network::Schema, + anchors: Vec, + remotes: BTreeMap, + 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, + 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, session: u64, host: Option) { + 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::() { + 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::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::(); + let skater = app.world().resource::(); + let physics = app.world().resource::(); + 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, receive) + .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); + } +} +fn receive(mut net: ResMut) { + 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, mut physics: ResMut, skater: Res) { + 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, + physics: Res, + mut label: Single<&mut Text, With>, +) { + ***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, physics: Res, skater: Res) { + 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, skater: Res) { + 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(); + } +} diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs new file mode 100644 index 00000000..afa29175 --- /dev/null +++ b/crates/skate-game/src/multiplayer/render.rs @@ -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, + rest: Vec, + parents: Vec, +} +#[derive(Default)] +struct RemoteSkin { + root: Option, + bindings: Vec<(Entity, usize, Option)>, + positions: Buffer<[f32; 3]>, + roots: Buffer, + poses: Buffer>, + clock: Clock, + epoch: u64, +} +pub(super) struct RemoteRenderPlugin; +impl Plugin for RemoteRenderPlugin { + fn build(&self, app: &mut App) { + let idle = app + .world() + .resource::() + .0 + .initial_animation + .clone(); + let skater = app.world().resource::(); + 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, + mut skins: ResMut, + server: Res, +) { + 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, + skater: Res, + 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 { + 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 { + 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, + mut skins: ResMut, + 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; + } +} diff --git a/crates/skate-game/src/multiplayer/transport.rs b/crates/skate-game/src/multiplayer/transport.rs new file mode 100644 index 00000000..a18f3f92 --- /dev/null +++ b/crates/skate-game/src/multiplayer/transport.rs @@ -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)>>; + 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 { + 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 { + 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 { + 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)>> { + 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, + cookie: String, + status: String, + last_ping: Instant, + started: Instant, + metrics: String, + congested: bool, + events: Vec, + pending: Option<(Request, Instant)>, + last_command: Instant, + request_id: u64, +} +impl Steam { + pub fn new(peer: u64, session: u64) -> Result { + 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 { + 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)>> { + 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 + } +} diff --git a/crates/skate-game/src/multiplayer_main.rs b/crates/skate-game/src/multiplayer_main.rs new file mode 100644 index 00000000..d96e5582 --- /dev/null +++ b/crates/skate-game/src/multiplayer_main.rs @@ -0,0 +1,2 @@ +// A unique build artifact for this worktree's multiplayer test launcher. +include!("main.rs"); diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 38f053cf..31f2ccc0 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -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; @@ -69,6 +70,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, @@ -226,6 +230,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, diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 2c9b97a1..db25d9e2 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -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 diff --git a/crates/skate-game/src/physics/network.rs b/crates/skate-game/src/physics/network.rs new file mode 100644 index 00000000..b61b7a48 --- /dev/null +++ b/crates/skate-game/src/physics/network.rs @@ -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 { + 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 { + 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, + pub volumes: Vec, +} +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, + }); + } + } +} diff --git a/crates/skate-game/src/physics/skeleton_feedback.rs b/crates/skate-game/src/physics/skeleton_feedback.rs index e72210ec..a5a2b9c3 100644 --- a/crates/skate-game/src/physics/skeleton_feedback.rs +++ b/crates/skate-game/src/physics/skeleton_feedback.rs @@ -113,7 +113,10 @@ fn collect(physics: &GamePhysics, skater: &SkaterRuntime) -> Vec= 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 { diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs index a6932a02..ea0567fb 100644 --- a/crates/skate-game/src/physics/solve.rs +++ b/crates/skate-game/src/physics/solve.rs @@ -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, diff --git a/crates/skate-game/src/physics/solve/assembly_contacts.rs b/crates/skate-game/src/physics/solve/assembly_contacts.rs index 28f990a9..304d2f4a 100644 --- a/crates/skate-game/src/physics/solve/assembly_contacts.rs +++ b/crates/skate-game/src/physics/solve/assembly_contacts.rs @@ -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, a: &BoardWorldVolume, b: &BoardWorldVolume) { +pub(super) fn append_pair(contacts: &mut Vec, 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( diff --git a/crates/skate-game/src/replay.rs b/crates/skate-game/src/replay.rs index 942b2102..a22d4d3d 100644 --- a/crates/skate-game/src/replay.rs +++ b/crates/skate-game/src/replay.rs @@ -159,7 +159,12 @@ fn controls( motion: Res, time: Res>, menu: Option>, + net: Option>, ) { + 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; diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index ed162da5..deb39050 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -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 { 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 diff --git a/crates/skate-net/Cargo.toml b/crates/skate-net/Cargo.toml new file mode 100644 index 00000000..3ea6c094 --- /dev/null +++ b/crates/skate-net/Cargo.toml @@ -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 diff --git a/crates/skate-net/examples/bandwidth_audit.rs b/crates/skate-net/examples/bandwidth_audit.rs new file mode 100644 index 00000000..597f193c --- /dev/null +++ b/crates/skate-net/examples/bandwidth_audit.rs @@ -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.); + } + } +} diff --git a/crates/skate-net/examples/lobby_audit.rs b/crates/skate-net/examples/lobby_audit.rs new file mode 100644 index 00000000..1588cfdd --- /dev/null +++ b/crates/skate-net/examples/lobby_audit.rs @@ -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, +} +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::::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::() as f64 / 225_000., + bytes[1..].iter().map(|b| b[1]).sum::() 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); +} diff --git a/crates/skate-net/src/codec.rs b/crates/skate-net/src/codec.rs new file mode 100644 index 00000000..5d5854b7 --- /dev/null +++ b/crates/skate-net/src/codec.rs @@ -0,0 +1,180 @@ +//! Fixed little-endian scalars, bounded counts, and no generic deserializer allocations. +use super::*; +struct Writer(Vec); +impl Writer { + fn byte(&mut self, v: u8) { + self.0.push(v); + } + fn floats(&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 { + 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(&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 { + Some(self.bytes::<1>()?[0]) + } + fn floats(&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 { + Some(Pose { + p: self.floats()?, + q: self.floats()?, + }) + } +} +pub(super) fn decode(data: &[u8]) -> Option { + 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, + }) +} diff --git a/crates/skate-net/src/directory.rs b/crates/skate-net/src/directory.rs new file mode 100644 index 00000000..1c253c7f --- /dev/null +++ b/crates/skate-net/src/directory.rs @@ -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, + }, + 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(value: &T) -> String { + serde_json::to_string(value).expect("Control serialization") +} +pub fn request(text: &str) -> Option { + 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 { + 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()); + } +} diff --git a/crates/skate-net/src/interpolation.rs b/crates/skate-net/src/interpolation.rs new file mode 100644 index 00000000..b7b2d9f7 --- /dev/null +++ b/crates/skate-net/src/interpolation.rs @@ -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 { + pub time: f64, + pub value: T, +} +#[derive(Debug)] +pub struct Buffer { + pub samples: VecDeque>, +} +impl Default for Buffer { + fn default() -> Self { + Self { + samples: VecDeque::new(), + } + } +} +impl Buffer { + 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, + 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, + local: Option, + 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 { + 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 + })) +} diff --git a/crates/skate-net/src/lib.rs b/crates/skate-net/src/lib.rs new file mode 100644 index 00000000..84051ffd --- /dev/null +++ b/crates/skate-net/src/lib.rs @@ -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::()) + } +} +#[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, + pub bodies: Vec, + pub volumes: Vec, +} +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::() + < 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::()) + && 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>, 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>>, + start: Instant, +} +#[derive(Default)] +pub struct Receiver { + pending: Vec, + completed: u64, +} +impl Receiver { + pub fn receive(&mut self, bytes: &[u8]) -> Option { + 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); + } +} diff --git a/crates/skate-net/src/lobby.rs b/crates/skate-net/src/lobby.rs new file mode 100644 index 00000000..8122847d --- /dev/null +++ b/crates/skate-net/src/lobby.rs @@ -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, + 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, +} +pub struct Session { + pub local: u64, + pub session: u64, + pub actors: BTreeMap, + pub stats: Stats, + pub notice: String, + host: Option, + links: BTreeMap, + pending: Vec, + 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) -> 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, 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) { + 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 { + 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 { + 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::>() { + 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::() + .sqrt() +} +fn write_info(b: &mut Vec, 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 { + Some(Info { + id: r.u64()?, + map: r.u64()?, + rig: r.u64()?, + physics: r.u64()?, + appearance: r.u64()?, + }) +} diff --git a/crates/skate-net/src/packed.rs b/crates/skate-net/src/packed.rs new file mode 100644 index 00000000..7638c100 --- /dev/null +++ b/crates/skate-net/src/packed.rs @@ -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>, +} +#[derive(Clone, Debug)] +pub struct BodyState { + pub root: Pose, + pub enabled: u64, + pub bodies: Vec, +} +#[derive(Clone, Debug)] +pub struct PoseState { + pub root: Pose, + pub bones: Vec, +} +pub fn header(session: u64, actor: u64, kind: u8, sequence: u32) -> Vec { + 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(&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 { + Some(self.take::<1>()?[0]) + } + pub fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take()?)) + } + pub fn u64(&mut self) -> Option { + 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::().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 { + 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, 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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::from_le_bytes(data.get(HEADER..HEADER + 4)?.try_into().ok()?).into() +} +pub fn apply(data: &[u8], base: Option<&Packed>) -> Option { + 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) +} diff --git a/crates/skate-net/tests/interpolation.rs b/crates/skate-net/tests/interpolation.rs new file mode 100644 index 00000000..8b49785e --- /dev/null +++ b/crates/skate-net/tests/interpolation.rs @@ -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); +} diff --git a/crates/skate-net/tests/physical_contact.rs b/crates/skate-net/tests/physical_contact.rs new file mode 100644 index 00000000..4b011220 --- /dev/null +++ b/crates/skate-net/tests/physical_contact.rs @@ -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()) + ); +} diff --git a/crates/skate-net/tests/v2.rs b/crates/skate-net/tests/v2.rs new file mode 100644 index 00000000..19d4a36c --- /dev/null +++ b/crates/skate-net/tests/v2.rs @@ -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::() + .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)); + } + } +} diff --git a/crates/skate-steam-relay/Cargo.toml b/crates/skate-steam-relay/Cargo.toml new file mode 100644 index 00000000..f5a827a9 --- /dev/null +++ b/crates/skate-steam-relay/Cargo.toml @@ -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" } diff --git a/crates/skate-steam-relay/src/directory.rs b/crates/skate-steam-relay/src/directory.rs new file mode 100644 index 00000000..43f954d1 --- /dev/null +++ b/crates/skate-steam-relay/src/directory.rs @@ -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, String>), + Host(u64, u64, u64, String, Result), + Join(u64, u64, u64, Result), +} +pub struct Directory { + pub lobby: Option, + pub owner: u64, + pub members: BTreeSet, + tx: Sender, + rx: Receiver, + request: Option, + pending: bool, + began: Instant, + cached: Option, + 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 { + 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 { + 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 { + 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) +} diff --git a/crates/skate-steam-relay/src/main.rs b/crates/skate-steam-relay/src/main.rs new file mode 100644 index 00000000..fbdf4451 --- /dev/null +++ b/crates/skate-steam-relay/src/main.rs @@ -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::().map_err(|_| "Invalid peer")?; + let mut session = args[2].parse::().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 = 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(()) +} diff --git a/docs/multiplayer-interpolation.md b/docs/multiplayer-interpolation.md new file mode 100644 index 00000000..c362c409 --- /dev/null +++ b/docs/multiplayer-interpolation.md @@ -0,0 +1,52 @@ +# Remote-player visual interpolation + +Use **PLAY-MULTIPLAYER-SMOOTH-2-INSTANCES.bat** for the new build. It starts two connected clients on University from `bin/multiplayer-interpolation-v4`; existing running games and the previous staged build are untouched. The launcher also accepts `-Players 10` and `-TwoControllers`. + +Both peers must use this build: timestamped state uses protocol magic `SK8NET04`. The previous v2 executable cannot join it. Steam remains optional and has its matching helper beside this build. + +## Head correction and localhost delay follow-up + +The default physical skeleton contains NECK and NECK1 but not the visual HEAD joint. The original remote fallback evaluated only the raw idle animation, omitting the RIG_TPOSE reference added by the normal animation graph. HEAD consequently inherited a missing local neck-to-head offset. The fix adds HEAD to the transmitted visual anchors (31 total) and composes RIG_TPOSE into the idle fallback for remaining untransmitted bones. + +The same launcher now selects the separate v4 build; restart both test clients with it. Existing v3 windows are not terminated or overwritten. + +Loopback-only sessions now capture/forward nearby pose updates at a target 20 Hz and use a 60–150 ms buffer range. The steady synthetic target was 62.5 ms and final visual age was 98.2 ms, including sampling phase and simulated 6 ms delivery. This is not a guarantee of live latency. Steam/LAN retain the more conservative timing profile and 10 Hz nearby pose target. The extra head anchor adds at most 12 bytes per compact full pose (120 bytes/s at 10 Hz). + +Playback now resets excessive accumulated delay after a sender stall as well as after a suspended receiving render loop, avoiding a long period of slow catch-up. Continuous focused and unfocused Winit updates were already enabled; no Steam, controller or driver settings were changed. Background rendering or CPU scheduling can still lower the actual update rate. + +Seventeen nongame tests passed, including new head-offset/bind-composition and localhost sender-stall recovery regressions. The observed live v3 session had median target buffers of 160/157 ms and a maximum of 1,312 ms on A; its maximum application ping was 985 ms. These indicate local scheduling/processing delays but do not isolate their cause. The fixed game was built without being launched; verify head placement and responsiveness in the next user test. + +## Original interpolation changes (online defaults) + +- Body/root and skeletal pose histories are independent. A physical update no longer adds an old pose with a new arrival time, which previously produced alternating holds and rapid blends. +- Every physical/pose snapshot carries an eight-byte source capture timestamp. The host preserves it while forwarding and delta encoding. Idle detection ignores timestamp-only changes. +- Each remote actor has one monotonic playback clock shared by its two histories. It estimates delivered cadence and arrival jitter, then adjusts playback speed gently to maintain a buffer. It starts with a 150 ms target, grows under jitter, and supports longer intervals for distant actors. This is buffering in addition to transmission latency, not a ping reduction. +- The renderer samples both histories each display frame. Root position uses limited cubic Hermite interpolation with tangents estimated from root samples at their own capture times. Limiting avoids overshoot at stops. Root and skeletal rotations retain quaternion SLERP; skeletal local positions blend linearly. +- All newly decoded samples are consumed, including packets delivered in one batch. Histories and resolved pose caches are bounded to 64 entries. Duplicate/older samples do not create new pose keys. +- Pose capture now runs after the frame's animation update instead of taking the previous render pose in the fixed physics schedule. Network targets remain 20 Hz physics and 10 Hz pose nearby. +- A large root discontinuity resets both visual histories. A long suspended render loop resets playback to a fresh buffer. Missing updates hold the last supported sample rather than extrapolating through geometry. This layer does not delay or otherwise change the local player's controls or the existing collision solver. +- HUD/logs show `Visual interpolation: ... ms target buffer | stalls ...`, with cumulative buffer-starvation episodes across visible actors. `MULTIPLAYER_INTERPOLATION` is logged once per second. + +## Verification + +Fifteen nongame tests passed, including production packet/session tests, actual ten-session loopback UDP, timestamp preservation across a host with a different local clock, timestamp-independent idle detection, bounded histories, duplicate rejection, root-curve endpoint/overshoot tests, pause/reset handling and interpolation at 60/120/144 FPS. + +The 29-second playback simulation uses 20 Hz movement and 10 Hz pose: + +| Conditions | Final target buffer | Buffer starvation episodes | +|---|---:|---:| +| Steady 40 ms delivery | 150 ms | 0 at each tested FPS | +| 5% independent loss, 30–90 ms delivery jitter | 190.9 ms | 4 at each tested FPS | + +The playhead remained monotonic and per-frame advancement stayed within the configured 8% catch-up speed. A few holds remain possible when loss exceeds the available buffer; arbitrary outages cannot be interpolated away. + +The ten-peer production network audit also passed. Busy guest upload was 23.11 kB/s versus 22.87 kB/s before timestamps (about 240 bytes/s extra). Crowded host upload remained at approximately 1.025 MB/s including controls. No send-rate increase was introduced. + +The game and Steam helper were built and staged without launching either. User testing is still needed for visual quality, fast flips, walking, bails, respawns and real online conditions. Sparse snapshots cannot recover a full spin absent from their endpoint rotations; this pass fixes timing and buffering, not animation-event reconstruction. + +## References informing the design + +- [Glenn Fiedler: Snapshot Interpolation](https://github.com/mas-bandwidth/gafferongames/blob/main/content/post/snapshot_interpolation.md) — buffered interpolation, Hermite curves, quaternion SLERP and extrapolation limitations. +- [Mirror's snapshot interpolation implementation](https://github.com/MirrorNetworking/Mirror/blob/master/Assets/Mirror/Core/SnapshotInterpolation/SnapshotInterpolation.cs) — source timelines, jitter-based buffer adjustment and gentle catch-up/slowdown. + +Rebuild with `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-multiplayer-test.ps1 -OutputDirectory bin/multiplayer-interpolation-v4`. diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md new file mode 100644 index 00000000..4dd5ef63 --- /dev/null +++ b/docs/multiplayer-test.md @@ -0,0 +1,60 @@ +> Latest build: **PLAY-MULTIPLAYER-MENU.bat**, staged in `bin/multiplayer-lobbies`. It opens University offline. Esc > Multiplayer provides public Steam hosting, a paginated lobby browser and joining by stable numeric lobby code. **PLAY-MULTIPLAYER-SMOOTH-2-INSTANCES.bat** uses this same build for the automatic two-player local test. Earlier performance results below are historical. + +## Steam lobby test (current build) + +Use the menu launcher on each PC with matching assets/build and separate Steam accounts. Choose Host via Steam / Spacewar on one PC. On the other, choose Browse public Steam lobbies, select the matching University row, then Resume. Refresh updates membership counts. You can also type the numeric lobby code and use Join via Steam. Steam is initialized only by Steam multiplayer actions; offline play remains available when Steam is closed. After opening Steam following an error, refresh or retry. + +Public discovery is filtered to this game's protocol namespace within AppID 480. Rows show map, occupancy and compatibility; incompatible maps/physics cannot join, and maps are not downloaded or switched automatically. Capacity is ten including the host. There is no friends list or invite UI. + +Steam lobby-owner reassignment triggers automatic transport rerouting and a fresh game handshake among the remaining members. Each survivor keeps their own simulated skater and local capture history; remote presentation rebuilds after reconnection. The numeric lobby code stays the same. Test host departure with three players, then verify the two survivors resume and a new player can join. Graceful Leave and an abrupt host exit should both be tested; abrupt failure waits for Steam's membership detection, so migration is not instantaneous. The helper also exits after 15 seconds without its parent heartbeat. Direct UDP tests and legacy SteamID-session codes have no automatic election. + +Twenty nongame tests passed, covering bounded discovery messages, Unicode labels, repeated ten-player session migration, local-state preservation, stale departed-host packets, production loopback packets, interpolation and physical contact. Release game/helper builds are staged. No game or Steam process was launched for this verification. Real public discovery, Steam relay connectivity and provider-driven migration still need the multi-PC test above. + +> New visual interpolation test: [multiplayer-interpolation.md](multiplayer-interpolation.md). Use its separate launcher for timestamped protocol v3. This page retains the original optimisation results. + +# Multiplayer optimisation / next test + +Run `PLAY-MULTIPLAYER-2-INSTANCES.bat`. It launches two connected games on **University** using the same ten-player session implementation as online play. Player A hosts; B uses an unavailable outfit identifier and spawns two metres away. Click a window to control that player with raw XInput. `-TwoControllers` assigns slots 0 and 1. Steam is only initialized when choosing Steam multiplayer from the menu. + +`PLAY-MULTIPLAYER-10-INSTANCES.bat` launches all ten local game windows. This also tests rendering and physics load on one PC; it is substantially heavier than one game with nine online players. Alternatively pass `-Players 3` through `-Players 10` to the two-instance launcher. Override the map with `-MapPath "C:/path/map.skate"`. + +The HUD shows player count, application upload/download in decimal kB/s, worst current direct-link ping, stale packets, missing delta baselines and send errors. Steam sessions additionally show SDK transfer rates, worst link quality estimate, queue time and adapter drops/errors. Host ping is to guests; guest ping is to the host, not the full guest-to-guest path. Sequence gaps are not reported as packet loss because distance throttling intentionally skips source updates. + +Test skating into each other, board impacts, tricks, bails, walking, board retrieval and switching window focus. Check the University scenery and remote pose/fallback. Logs are in `logs/multiplayer//player-*.err.log`, including one `MULTIPLAYER_STATS` sample per second. Opening the menu keeps the online simulation running; replay remains disabled during multiplayer. + +## Online setup + +On one PC choose **Esc > Multiplayer > Host via Steam / Spacewar**. On each other PC/account enter the same host code in the multiplayer menu and choose Join. Up to nine guests can join. Everyone needs this protocol version and the same map and physical definitions. Visual outfit/rig differences use the default skater. Unknown identifiers never become asset paths. An incompatible visual hierarchy uses the default idle pose instead of foreign bone indices. + +The host is an ordinary player, not a dedicated server. All guests connect to that host; it forwards the newest state to the other players. Transport, player IDs, codec, membership, budgets and rendering are independent of Steam. Spacewar/AppID 480 is the optional connection/relay provider. The game has no Steam DLL dependency and neither executable invokes Steam Input. Two processes under one Steam account still require the local launcher for testing. + +Guests can reconnect; timed-out actors are removed and slots reused. The current Steam lobby browser and migration flow are described above. Legacy direct sessions require manually starting/joining another host after departure. + +## Optimisations + +- Version 2 separates physical state and visual anchors into independent datagrams. Full 33-body updates are 825 bytes normally or 1,023 bytes with every position outside the compact range. Thirty compact anchors use 426 bytes. There is no fragment reassembly or lost-fragment amplification. +- Millimetre root-relative positions, four-byte quaternions and half-float velocities. World roots and large relative offsets retain floats. Quantisation adds approximately 0.5 mm per axis plus float precision; quaternion error is bounded by the regression test at 0.006 radians for its samples. This is lossy replication, not deterministic simulation state. +- Per-recipient acknowledged deltas, 64-revision bounded histories and periodic full refreshes. The next delta does not depend on an unacknowledged lost packet. +- Static stock collider geometry stays local. Physical state carries an enabled-part mask and ragdoll flag. Nearby remote bodies join the existing solver with unique reaction indices; distant assemblies are excluded and primitive bounding spheres reject separated pairs before narrow phase. Proxy buffers and resolved remote animation samples are reused/cached. +- Guest capture targets 20 Hz physics and 10 Hz anchors. Host forwarding targets 20/10 Hz within 30 metres, 10/4 Hz within 100 metres and 2/1 Hz beyond. Detached boards are included in distance checks. Unchanged state slows further except nearby physical state. Guest uploads always retain the full rate so two guests near each other do not depend on proximity to the host. +- Physical updates take priority under upload pressure. A latest-state scheduler rotates actors and recipients; stale states are replaced, not queued. State budgets are 180 kB/s per link and 1 MB/s aggregate host; control traffic is additional. Crowded lobbies therefore reduce delivered rates to stay within the host budget. +- Steam state sends use unreliable/no-delay delivery, explicit drop/error counters and queue-pressure feedback. Handshake/control uses unreliable/no-Nagle so Steam can establish the connection. Controls repeat periodically. No reliable snapshot backlog. + +Physical collisions still use local ownership with temporary remote proxies, not shared authority or rollback. Up to 50 ms translation prediction, 100 ms visual interpolation and stale-state expiry limit extrapolation; delayed impacts can remain asymmetric. Climbing retains its existing special movement path. This pass does not claim retail Skate 3 online synchronisation or competitive anti-cheat. + +## Verification + +Release game and relay built and staged using `BUILD-MULTIPLAYER-TEST.bat`, which launches neither. Ten nongame tests passed: existing wire validation, real UDP, quantisation/deltas, truncation, full/mismatched admission, spoof rejection, disconnect/rejoin and a real core-solver contact using the last of nine 33-body remote blocks. The launcher was parsed without execution. + +`cargo run --release --target x86_64-pc-windows-msvc -p skate-net --example lobby_audit --locked` runs the production codec/session with ten simulated peers for 30 seconds per scenario, measuring the final 25 seconds. It introduces independent 30–90 ms delay per link, reorder and optional 5% loss. The near scenario changes every body and anchor continuously. It checks all ten memberships, bounded packet sizes, replication freshness, and zero invalid packets/missing acknowledged baselines. It does not run a game, the renderer, Steam or a real WAN. + +| Synthetic ten-player scenario | Host up/down kB/s | Guest average up/down kB/s | +|---|---:|---:| +| Moving together, no loss | 1,025.51 / 205.81 | 22.87 / 113.95 | +| Moving together, 5% loss | 1,025.48 / 195.23 | 22.87 / 108.09 | +| Moving 150 m apart, 5% loss | 193.65 / 195.70 | 22.87 / 20.44 | +| Idle together, 5% loss | 189.56 / 36.75 | 4.30 / 20.01 | + +These are application datagrams including controls, before UDP/IP/Steam overhead. Crowded host upload is approximately **8.20 Mbps**, guest upload **0.18 Mbps**, guest download **0.86 Mbps** with loss. The host upload is the limiting connection. Choose a host with headroom above those figures. Measured crowded maximum gaps between received body/pose updates were 350/590 ms under 5% loss; this is not a bound for arbitrary internet conditions. + +The previous representative serializer used 117.6 kB/s per direction for one peer. The new busy guest upload is about 81% smaller despite the ten-player control overhead. Real game rates depend on motion, distance and relay conditions. In-game feel, ten real clients' performance and Steam connectivity still need the user-run test; no game or Steam process was launched during this pass. diff --git a/docs/network-bandwidth-audit.md b/docs/network-bandwidth-audit.md new file mode 100644 index 00000000..b9a2d3b7 --- /dev/null +++ b/docs/network-bandwidth-audit.md @@ -0,0 +1,40 @@ +> Historical v1 baseline. Production now uses v2. See [the optimisation results and next test](multiplayer-test.md) for the ten-player implementation and measured synthetic rates. + +# Networking bandwidth audit — 8 September 2026 + +Verdict: bandwidth is workable for a two-player prototype on a connection with sufficient spare capacity, but this implementation is not yet optimized or qualified for internet P2P. Snapshot loss, stale queues, and collision latency need attention alongside bandwidth. + +## Method + +Inspected the game capture/send code, production binary serializer/reassembler, direct adapter, and Steam helper. Ran `cargo run --release --target x86_64-pc-windows-msvc -p skate-net --example bandwidth_audit --locked`. This is a nongame serializer harness. It does not launch a game, initialize Steam, or measure the user's internet connection. Raw results: `logs/multiplayer-build/bandwidth-audit.log`. + +The current game captures up to 30 animation anchors, exactly 33 physical bodies, and up to 64 enabled collision primitives. Actual active primitive counts depend on gameplay state and board geometry. No live packet capture or per-session bandwidth counters are available in the existing logs. Therefore the scenarios below are explicit synthetic examples/bounds, not measured University gameplay averages. + +## Results per player, with one other player + +All kB values are decimal. Upload and download are approximately equal when both players send comparable snapshots at the same rate. Application bytes include the 36-byte header on every fragment, but exclude UDP/IP and Steam's encryption, transport headers, acknowledgements, and connection establishment traffic. The localhost IPC leg of the Steam helper is not an extra internet upload. + +| Scenario | Bytes/update | Fragments | Upload at 30 Hz | Download at 30 Hz | +|---|---:|---:|---:|---:| +| 30 anchors, no active volumes; default appearance | 2,796 | 3 | 83.88 kB/s / 0.671 Mbps | 83.88 kB/s / 0.671 Mbps | +| 30 anchors, 32 capsules; illustrative example | 3,920 | 4 | 117.60 kB/s / 0.941 Mbps | 117.60 kB/s / 0.941 Mbps | +| 30 anchors, 64 boxes, maximum appearance string | 5,960 | 6 | 178.80 kB/s / 1.430 Mbps | 178.80 kB/s / 1.430 Mbps | +| Maximum accepted protocol: 32 anchors, 64 boxes | 6,020 | 6 | 180.60 kB/s / 1.445 Mbps | 180.60 kB/s / 1.445 Mbps | + +The send gate is 32 ms of wall time, checked after fixed physics updates. Approximately 30 Hz is expected with smoothly scheduled 60 Hz physics, but it is not an exact independent network clock. Its long-run ceiling is 31.25 updates/sec. At that ceiling the accepted protocol maximum is **188.125 kB/s / 1.505 Mbps each direction**. IPv4+UDP alone brings it to 1.547 Mbps; Steam overhead has not been measured. The actual rate may be lower with frame stalls or batched fixed updates. + +Frame payload size is `56 + appearance_bytes + 30*anchor_count + 52*33 + volume_bytes`. Each sphere costs 18 bytes, capsule 34, box 46, triangle 42. Each 1,000-byte payload fragment adds a 36-byte header. + +## Problems found + +1. Every update sends full absolute state, including unchanged state. There are no deltas, sleeping/idle bandwidth reductions, or quantized values. The same motion is described separately through body poses, final animation anchors, and world-space collider geometry. +2. A snapshot is unusable unless every fragment arrives. With independent 1% fragment loss, a four-fragment snapshot fails 3.94% of the time; a six-fragment snapshot fails 5.85%. At 5% loss those become 18.55% and 26.49%. These are mathematical scenarios, not observed Steam loss rates. Burst loss or transport packet aggregation changes the distribution. Steam's [unreliable-message contract](https://partner.steamgames.com/doc/api/ISteamNetworkingMessages) explicitly allows loss, duplicates, and reordering. +3. The Steam helper uses `UNRELIABLE_NO_NAGLE`, not the no-delay/drop-stale option. It discards `send_message_to_user` errors and does not inspect pending bytes, queue delay, RTT, or actual send rate. It cannot currently adapt its application update budget to a congested connection. +4. The bundled Steamworks SDK header documents a default 256K bytes/sec send rate. The computed application budget is below that, but this is not evidence that every internet path can sustain it. The application does not query the effective connection rate or establish real transport headroom. +5. The implementation admits two players only. These numbers are per peer relationship, not a constant budget for a future larger session. Routing and interest management would determine scaling. + +## Recommended next work + +Derive default collision geometry locally from body states and compact enable flags; exchange static appearance/rig information during admission. Quantize position, rotation, and velocity with explicit error budgets. Separate essential root/body updates from optional pose detail so one lost fragment need not discard everything. Add actual up/down counters, completed-snapshot rate, RTT/loss/queue metrics, send-error reporting, and a stale-snapshot/backpressure policy. Then qualify two-PC Steam play with simulated latency, jitter, and packet loss. Collision consistency remains a separate limitation of locally owned physics. + +No gameplay behavior, packet format, or executable was changed by this audit. diff --git a/docs/steam-p2p-plan.md b/docs/steam-p2p-plan.md index ee8a10ad..d7ae4ce6 100644 --- a/docs/steam-p2p-plan.md +++ b/docs/steam-p2p-plan.md @@ -1,5 +1,9 @@ +> Current implementation: ten-player transport-neutral host/guest sessions with v2 compression and scheduling. See [multiplayer-test.md](multiplayer-test.md). Earlier planning/baseline notes below are retained for context. + # Steam P2P multiplayer plan +> **Superseded design:** this document records the original planning pass. The user's subsequent implementation request requires collisions and a transport-neutral game. See [the implemented two-player test](multiplayer-test.md) for current behavior. Its first collision implementation uses locally owned physics with dynamic remote proxies, not the no-collision recommendation below. Steam is an optional out-of-process byte relay, and local testing works without Steam. + Research date: 8 September 2026. Inspected engine baseline: `da364c9` (Bevy **0.18.1**, with local `bevy_pbr` and `bevy_core_pipeline` patches). **Design only; no multiplayer implementation is authorized by this document.** ## Recommendation diff --git a/scripts/build-multiplayer-test.ps1 b/scripts/build-multiplayer-test.ps1 new file mode 100644 index 00000000..f535124d --- /dev/null +++ b/scripts/build-multiplayer-test.ps1 @@ -0,0 +1,27 @@ +param([string]$OutputDirectory = 'bin/multiplayer') +$ErrorActionPreference = 'Stop' +$workspace = Split-Path -Parent $PSScriptRoot +Set-Location -LiteralPath $workspace +$env:CARGO_TARGET_DIR = 'C:/Users/Daddy/Documents/skate3-imported/target' +$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static' +& cargo build --release --target x86_64-pc-windows-msvc -p skate-game --bin skate3-multiplayer --no-default-features --locked +if ($LASTEXITCODE -ne 0) { throw 'Multiplayer build failed.' } +& cargo build --release --target x86_64-pc-windows-msvc -p skate-steam-relay --locked +if ($LASTEXITCODE -ne 0) { throw 'Steam relay build failed.' } +$output = Join-Path $workspace $OutputDirectory +$relay = Join-Path $output 'steam-relay' +New-Item -ItemType Directory -Path $relay -Force | Out-Null +$release = Join-Path $env:CARGO_TARGET_DIR 'x86_64-pc-windows-msvc/release' +Copy-Item -LiteralPath (Join-Path $release 'skate3-multiplayer.exe') -Destination $output +Copy-Item -LiteralPath (Join-Path $release 'skate-steam-relay.exe') -Destination $relay +$metadata = (& cargo metadata --format-version 1 --locked) -join "`n" +if ($LASTEXITCODE -ne 0) { throw 'Could not locate the Steam redistributable.' } +# Extract only the JSON string we need. Windows PowerShell's JSON object parser +# rejects unrelated dependency feature keys that differ only in letter case. +$sdk = [regex]::Match($metadata, '"name":"steamworks-sys","version":"0\.13\.0".*?"manifest_path":("(?:[^"\\]|\\.)*")') +if (-not $sdk.Success) { throw 'Steamworks SDK package was not found in Cargo metadata.' } +$manifest = ConvertFrom-Json -InputObject $sdk.Groups[1].Value +$dll = Join-Path (Split-Path -Parent $manifest) 'lib/steam/redistributable_bin/win64/steam_api64.dll' +Copy-Item -LiteralPath $dll -Destination $relay +Write-Host "Staged multiplayer build: $output" +Write-Host 'Build only; no game or Steam process was launched.' diff --git a/scripts/launch-multiplayer-test.ps1 b/scripts/launch-multiplayer-test.ps1 new file mode 100644 index 00000000..ea441d82 --- /dev/null +++ b/scripts/launch-multiplayer-test.ps1 @@ -0,0 +1,42 @@ +param([switch]$MenuOnly, [string]$MapPath = '', [switch]$TwoControllers, [ValidateRange(2,10)][int]$Players = 2, [string]$BuildDirectory = 'bin/multiplayer') +$ErrorActionPreference = 'Stop' +$instanceCount = if ($MenuOnly) { 1 } else { $Players } +$workspace = Split-Path -Parent $PSScriptRoot +$binary = Join-Path (Join-Path $workspace $BuildDirectory) 'skate3-multiplayer.exe' +$installation = 'C:/Users/Daddy/AppData/Local/Skate3RustEngine/installations/957f4f78db9b4f59959f10dff8ff6a84' +$assets = Join-Path $installation 'assets' +if (-not (Test-Path -LiteralPath $binary)) { throw 'Build missing. Run BUILD-MULTIPLAYER-TEST.bat first.' } +if (-not (Test-Path -LiteralPath (Join-Path $assets 'private/game.json'))) { throw 'Prepared game assets are missing. No installer or conversion will be started.' } +if (-not $MapPath) { $MapPath = Join-Path $installation 'maps/University.skate' } +$MapPath = (Resolve-Path -LiteralPath $MapPath).Path +if ([IO.Path]::GetExtension($MapPath) -ne '.skate') { throw 'Expected an existing .skate map.' } +$logs = Join-Path $workspace ('logs/multiplayer/' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [Guid]::NewGuid().ToString('N').Substring(0,8)) +New-Item -ItemType Directory -Path $logs -Force | Out-Null +# Reserve distinct ports, then release immediately before the games start. +$reservations = @(1..$instanceCount | ForEach-Object { [Net.Sockets.UdpClient]::new([Net.IPEndPoint]::new([Net.IPAddress]::Loopback,0)) }) +$ports = @($reservations | ForEach-Object { $_.Client.LocalEndPoint.Port }) +$session = Get-Random -Minimum 1 -Maximum ([long]::MaxValue) +$reservations | ForEach-Object { $_.Dispose() } +$common = '--assets "' + $assets + '" --map "' + $MapPath + '" --net-session ' + $session +for ($index = 0; $index -lt $instanceCount; $index++) { + $label = [string][char](65 + $index) + $playerArgs = $common + if ($MenuOnly) { + # Start offline; Steam initializes only through the multiplayer menu. + } elseif ($index -eq 0) { + $playerArgs += ' --net-host 127.0.0.1:' + $ports[0] + } else { + $playerArgs += ' --net-local 127.0.0.1:' + $ports[$index] + ' 127.0.0.1:' + $ports[0] + # Guests advertise an unavailable outfit to exercise default fallback. + $playerArgs += ' --spawn-offset ' + ($index * 2).ToString([Globalization.CultureInfo]::InvariantCulture) + ' --appearance test-uninstalled-outfit' + } + $playerArgs += ' --player-title "Multiplayer ' + $label + ' - ' + $Players + ' player test"' + if ($TwoControllers -and $index -lt 2) { $playerArgs += ' --controller ' + $index } + $process = Start-Process -FilePath $binary -ArgumentList $playerArgs -WorkingDirectory $workspace -WindowStyle Normal -RedirectStandardOutput (Join-Path $logs ('player-' + $label.ToLower() + '.out.log')) -RedirectStandardError (Join-Path $logs ('player-' + $label.ToLower() + '.err.log')) -PassThru + Write-Host "Started player $label ($($process.Id))" +} +if ($MenuOnly) { Write-Host "Press Esc, then Multiplayer to host or browse Steam lobbies." } +else { Write-Host "$Players players connect automatically on University; A hosts the lobby." } +Write-Host 'Steam is not required. Click a game window to control that player with your controller.' +Write-Host 'For two pads, use -TwoControllers (XInput slots 0 and 1). Use -Players 10 for the full lobby.' +Write-Host "Logs: $logs"