From 24ac089874058fb04c336350fffbbcefc82eb9a7 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 23:29:13 +0100 Subject: [PATCH] Replicate SDK mod state and add multiplayer vehicle collision proxies --- Build-VehicleSDK.ps1 | 20 +- PLAY-MARIO-KART.bat | 5 +- README.md | 2 +- crates/skate-game/src/animation.rs | 16 + crates/skate-game/src/graphics_menu.rs | 2 +- crates/skate-game/src/modding/mod.rs | 8 +- crates/skate-game/src/modding/network.rs | 335 +++++++++++++++++ .../skate-game/src/modding/vehicles/audio.rs | 84 ++--- crates/skate-game/src/modding/vehicles/mod.rs | 24 +- .../src/modding/vehicles/network.rs | 351 ++++++++++++++++++ crates/skate-game/src/multiplayer.rs | 37 +- crates/skate-game/src/multiplayer/render.rs | 11 +- crates/skate-game/src/physics/network.rs | 23 ++ crates/skate-mods/src/api.lua | 10 + crates/skate-mods/src/lib.rs | 3 +- crates/skate-mods/src/vm.rs | 4 +- crates/skate-mods/tests/runtime.rs | 34 ++ crates/skate-net/src/directory.rs | 2 +- crates/skate-net/src/lobby.rs | 77 +++- crates/skate-net/src/packed.rs | 8 +- crates/skate-net/tests/mods.rs | 119 ++++++ crates/skate-net/tests/v2.rs | 10 + crates/skate-vehicles/src/lib.rs | 4 +- crates/skate-vehicles/src/safety.rs | 2 +- crates/skate-vehicles/tests/simulation.rs | 26 ++ docs/lua-modding.md | 16 +- docs/mod-packages.md | 7 + docs/multiplayer-interpolation.md | 8 + docs/multiplayer-mods.md | 139 +++++++ docs/multiplayer-test.md | 12 +- docs/steam-p2p-plan.md | 6 + docs/vehicle-sdk.md | 20 +- mods/README.md | 7 + sdk/AGENTS.md | 11 +- sdk/README.md | 7 + sdk/examples/mario-kart/README.md | 7 + sdk/examples/native-trainer/README.md | 7 + sdk/skate.lua | 21 ++ 38 files changed, 1394 insertions(+), 91 deletions(-) create mode 100644 crates/skate-game/src/modding/network.rs create mode 100644 crates/skate-game/src/modding/vehicles/network.rs create mode 100644 crates/skate-net/tests/mods.rs create mode 100644 docs/multiplayer-mods.md diff --git a/Build-VehicleSDK.ps1 b/Build-VehicleSDK.ps1 index 7361af31..8b4d6ef2 100644 --- a/Build-VehicleSDK.ps1 +++ b/Build-VehicleSDK.ps1 @@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop' $privateDirectory = Join-Path $PSScriptRoot '.local/vehicle-sdk' $buildDirectory = Join-Path $privateDirectory 'build' $artifact = Join-Path $buildDirectory 'skate3rust-vehicle-sdk.exe' -$destination = Join-Path $privateDirectory 'skate3-vehicle-bails.exe' +$destination = Join-Path $privateDirectory 'skate3-multiplayer-mods.exe' Push-Location $PSScriptRoot $previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS try { @@ -14,7 +14,7 @@ try { # artifact directly, never staging the cache's generic skate3rust.exe. # Shared target fingerprints can refer to another worktree's local crates. # Refresh only our local source timestamps; never clean the shared cache. - foreach ($crate in @('skate-core', 'skate-data', 'skate-mods', 'skate-vehicles')) { + foreach ($crate in @('skate-core', 'skate-data', 'skate-mods', 'skate-vehicles', 'skate-net')) { (Get-Item -LiteralPath (Join-Path $PSScriptRoot "crates/$crate/src/lib.rs")).LastWriteTime = Get-Date } & cargo rustc --release --locked --target x86_64-pc-windows-msvc ` @@ -23,9 +23,22 @@ try { -o (Join-Path $buildDirectory 'skate3rust.exe') if ($LASTEXITCODE -ne 0) { throw 'Lua SDK build failed.' } } + $relayDirectory = Join-Path $privateDirectory 'steam-relay' + New-Item -ItemType Directory -Force -Path $relayDirectory | Out-Null + if (-not $StageOnly) { + & cargo rustc --release --locked --target x86_64-pc-windows-msvc --target-dir $DependencyTargetDirectory -p skate-steam-relay --bin skate-steam-relay -- -o (Join-Path $relayDirectory 'skate-steam-relay.exe') + if ($LASTEXITCODE -ne 0) { throw 'Steam relay build failed.' } + } + $metadata = (& cargo metadata --format-version 1 --locked) -join "`n" + if ($LASTEXITCODE -ne 0) { throw 'Could not locate Steam redistributable.' } + $sdk = [regex]::Match($metadata, '"name":"steamworks-sys","version":"0\.13\.0".*?"manifest_path":("(?:[^"\\]|\\.)*")') + if (-not $sdk.Success) { throw 'Steamworks SDK package was not found.' } + $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 $relayDirectory -Force $bytes = [System.IO.File]::ReadAllBytes($artifact) $text = [System.Text.Encoding]::ASCII.GetString($bytes) - foreach ($required in @('--start-paused', 'Wheel or drag scrollbar', 'Drag // to resize', 'hold_fakie', 'Vehicle key already spawned', 'ZIP must contain mod.json at its root', 'Native trainer controls are already owned by another mod')) { + foreach ($required in @('--start-paused', 'Wheel or drag scrollbar', 'Drag // to resize', 'hold_fakie', 'Vehicle key already spawned', 'ZIP must contain mod.json at its root', 'Native trainer controls are already owned by another mod', 'Matching mods synchronized', 'SK8NET05')) { if (-not $text.Contains($required)) { throw "Wrong vehicle-sdk artifact: missing $required" } } Copy-Item -LiteralPath $artifact -Destination $destination -Force @@ -52,6 +65,7 @@ try { Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/mod-packages.md') -Destination (Join-Path $privateDirectory 'docs/mod-packages.md') -Force Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/lua-modding.md') -Destination (Join-Path $privateDirectory 'docs/lua-modding.md') -Force Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'sdk/skate.lua') -Destination (Join-Path $privateDirectory 'sdk/skate.lua') -Force + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/multiplayer-mods.md') -Destination (Join-Path $privateDirectory 'docs/multiplayer-mods.md') -Force $hash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash if ($hash -ne (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash) { throw 'Staged executable hash mismatch.' diff --git a/PLAY-MARIO-KART.bat b/PLAY-MARIO-KART.bat index 5f6695f2..14469383 100644 --- a/PLAY-MARIO-KART.bat +++ b/PLAY-MARIO-KART.bat @@ -9,7 +9,7 @@ if not defined SKATE3_ASSETS ( pause exit /b 1 ) -if not exist "%~dp0.local\vehicle-sdk\skate3-vehicle-bails.exe" ( +if not exist "%~dp0.local\vehicle-sdk\skate3-multiplayer-mods.exe" ( echo Build missing. Run Build-VehicleSDK.ps1 first. pause exit /b 1 @@ -19,7 +19,8 @@ set "SKATE3_MOD_SETTINGS=%~dp0.local\vehicle-sdk\settings" if exist "%~dp0.local\vehicle-sdk\overlay.path" set /p "SKATE3_SESSION_MARKER_OVERLAY="<"%~dp0.local\vehicle-sdk\overlay.path" echo Open Mods and ENABLE Mario Kart. F10 spawns the kart; E enters/exits. Escape shows draggable settings windows. Drag the title to move, drag // to resize, or scroll for more settings. echo WASD drive, Space brake, Shift handbrake, R reset. Slow down before exiting. +echo Escape ^> Multiplayer to host or join. Use matching enabled mod ZIPs on both PCs. echo Mods folder: %SKATE3_MODS% echo Trainer log: %~dp0.local\vehicle-sdk\session.log -"%~dp0.local\vehicle-sdk\skate3-vehicle-bails.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1 +"%~dp0.local\vehicle-sdk\skate3-multiplayer-mods.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1 if errorlevel 1 pause diff --git a/README.md b/README.md index 859b4cdf..405a7920 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Includes skating, tricks, grinds, offboard movement, difficulty settings and Drop mod ZIPs into top-level `mods/` and use `PLAY-MARIO-KART.bat` for the integrated mod build in this checkout. **Point your coding agent at [sdk/AGENTS.md](sdk/AGENTS.md)** to make a mod. See [package structure](docs/mod-packages.md), [Lua API](docs/lua-modding.md), -[vehicle API](docs/vehicle-sdk.md) and [Mixamo workflow](docs/mixamo-vehicle-workflow.md). +[vehicle API](docs/vehicle-sdk.md), [multiplayer mod SDK](docs/multiplayer-mods.md) and [Mixamo workflow](docs/mixamo-vehicle-workflow.md). Editable examples live in `sdk/examples/`; `tools/package_mod.py` validates and packages them. ## Play diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 53e39b28..38bdf91b 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -244,3 +244,19 @@ pub(crate) fn capture_vehicle_visual(world: &mut World) -> Vec<(Entity, Transfor pub(crate) fn blend_vehicle_visual(world:&mut World, from:&[(Entity,Transform)], alpha:f32) { for &(id,previous) in from {if let Some(mut current)=world.get_mut::(id) {*current=crate::presentation::blend(previous,*current,alpha);}} } + +/// Read the final displayed hierarchy, including vehicle steering and stance blends. +pub(crate) fn network_visual(world: &mut World) -> skate_net::packed::PoseState { + let root=world.query_filtered::<&Transform,With>().iter(world).next().copied().unwrap_or_default(); + let status=world.resource::(); + let mut locals=std::collections::BTreeMap::new(); + for b in &status.bindings {if let Some(t)=world.get::(b.entity) {locals.entry(b.bone).or_insert((b.parent_bone,t.to_matrix()));}} + fn global(i:usize,locals:&std::collections::BTreeMap,Mat4)>,depth:usize)->Mat4 { + if depth>128 {return Mat4::IDENTITY;} + let Some(&(parent,m))=locals.get(&i) else {return Mat4::IDENTITY;}; + parent.map_or(m,|p|global(p,locals,depth+1)*m) + } + let anchors=crate::physics::network::anchors(world.resource::()); + let basis=render_basis().inverse(); + skate_net::packed::PoseState{root:crate::physics::network::pose(root.to_matrix()),bones:anchors.into_iter().map(|i|skate_net::Bone{index:i as u16,pose:crate::physics::network::pose(if locals.contains_key(&i) {global(i,&locals,0)*basis} else {native_matrix(world.resource::().render_pose[i])})}).collect()} +} diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 42792e04..01995cca 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -624,7 +624,7 @@ fn labels( } ) } else { - menu.status.clone() + if transition.busy() { format!("Loading{}", ".".repeat((time.elapsed_secs()*2.) as usize % 4)) } else {menu.status.clone()} }; for (row, interaction, mut color, mut node) in &mut buttons { node.display = if (menu.multiplayer || menu.browser) && row.0 >= 11 { Display::None } else { Display::Flex }; diff --git a/crates/skate-game/src/modding/mod.rs b/crates/skate-game/src/modding/mod.rs index 9b4e0efb..9abb859b 100644 --- a/crates/skate-game/src/modding/mod.rs +++ b/crates/skate-game/src/modding/mod.rs @@ -1,5 +1,6 @@ //! Main-thread SDK adapter. Lua never receives World, entity IDs, or asset handles. mod menu; +pub(crate) mod network; mod panel; pub(crate) mod vehicles; use bevy::prelude::*; @@ -92,6 +93,7 @@ impl Plugin for ModdingPlugin { ) .add_systems(Update, update.after(crate::app::FrameSet::Animation)); vehicles::install(app); + network::install(app); menu::install(app); panel::install(app); } @@ -115,7 +117,7 @@ fn snapshot(world: &World) -> serde_json::Value { let vehicle_pose=world.resource::().player_pose(); let player_position=vehicle_pose.map(|p|p.0).unwrap_or_else(||s.animated_skeleton.roots.animation_to_world[3][..3].try_into().unwrap()); json!({"player":{"position":player_position,"velocity": &p.skateboard.vector_80.map(f32::from_bits)[..3],"heading":vehicle_pose.map(|p|p.1).unwrap_or_else(||s.animated_skeleton.roots.animation_to_world[2][0].atan2(s.animated_skeleton.roots.animation_to_world[2][2])),"on_board":p.state.category_12!=500,"state":p.state.state_16,"category":p.state.category_12,"bailing":physics.board_wiping_out,"grind":{"active":grinding,"name":grind_name,"kind":grind_kind,"distance":grind_distance}}, - "vehicles":vehicles::snapshot(world),"vehicle_input":vehicles::input(world), + "network":network::snapshot(world),"vehicles":vehicles::snapshot(world),"vehicle_input":vehicles::input(world), "animation":world.resource::().animation_info, "map":{"name":map.name,"generation":map.generation},"tick":physics.ticks,"keys":keys,"actions":actions, "paused":world.resource::().open,"replay":world.resource::().active}) @@ -129,6 +131,7 @@ fn maintenance(world: &mut World) { .generation; if mods.generation != generation { vehicles::clear(world); + network::clear(world); let ids: Vec<_> = mods.owned.keys().cloned().collect(); for key in ids { retire(world, &mut mods, &key); @@ -230,6 +233,7 @@ fn apply(world: &mut World, mods: &mut Mods) { world.resource_mut::().trainer = mods.trainer.as_ref().map(|(_, t)| *t).unwrap_or_default(); for id in &retired { + network::retire(world,id); vehicles::retire(world, id); world .resource::() @@ -361,7 +365,9 @@ fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> return Err("64 owned objects per mod maximum".into()); } } + if !id.starts_with('@') { network::record(world, id, &command)?; } match command { + Command::NetworkState { .. } => {}, command @ (Command::VehicleTune{..}|Command::VehicleSpawn{..}|Command::VehicleRemove{..}|Command::VehicleEnter{..}|Command::VehicleExit{..}|Command::VehicleReset{..}|Command::VehicleControl{..}) => { vehicles::command(world, &mods.manager.packages[id].root, id, command)?; } diff --git a/crates/skate-game/src/modding/network.rs b/crates/skate-game/src/modding/network.rs new file mode 100644 index 00000000..358ca72d --- /dev/null +++ b/crates/skate-game/src/modding/network.rs @@ -0,0 +1,335 @@ +//! Generic SDK replication. Only validated data from matching local packages is applied. +use super::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +pub(super) enum Payload { + Cube { + key: String, + position: [f32; 3], + size: [f32; 3], + color: [f32; 3], + }, + State { + key: String, + value: Value, + }, + Tune { + key: String, + values: BTreeMap, + }, + Car { + key: String, + state: vehicles::network::Car, + }, +} +use serde_json::Value; +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + owner: String, + fingerprint: u64, + payload: Payload, +} +#[derive(Resource, Default)] +pub(crate) struct ModSync { + local: BTreeMap<(String, String), Payload>, + published: BTreeSet, + remote: BTreeMap<(u64, String), (u32, String, String)>, + states: BTreeMap>>, + identity: u64, + elapsed: f32, + pub status: String, +} +pub(super) fn install(app: &mut App) { + app.init_resource::() + .add_systems(Update, synchronize.after(vehicles::present)); +} +pub(super) fn snapshot(world: &World) -> Value { + let (active, id, host) = world + .get_resource::() + .map_or((false, 0, true), |n| n.mod_identity()); + let sync = world.resource::(); + json!({"active":active,"local_id":id.to_string(),"is_host":host,"states":sync.states,"status":sync.status}) +} +fn wire_key(owner: &str, key: &str) -> String { + format!( + "{:016x}", + skate_net::hash(format!("{owner}/{key}").as_bytes()) + ) +} +pub(super) fn record(world: &mut World, owner: &str, command: &Command) -> Result<(), String> { + // Remote descriptors are installed directly and never pass back into this path. + let mut sync = world.resource_mut::(); + let entry = match command { + Command::Cube { + key, + position, + size, + color, + } => Some(( + format!("cube/{key}"), + Payload::Cube { + key: key.clone(), + position: *position, + size: *size, + color: *color, + }, + )), + Command::Remove { key } | Command::Overlay { key, .. } => { + sync.local.remove(&(owner.into(), format!("cube/{key}"))); + None + } + Command::VehicleRemove { key } => { + sync.local.remove(&(owner.into(), format!("tune/{key}"))); + None + } + Command::NetworkState { key, value } => Some(( + format!("state/{key}"), + Payload::State { + key: key.clone(), + value: value.clone(), + }, + )), + Command::VehicleTune { key, tuning } => { + let slot = (owner.into(), format!("tune/{key}")); + let mut values = match sync.local.get(&slot) { + Some(Payload::Tune { values, .. }) => values.clone(), + _ => BTreeMap::new(), + }; + if let Value::Object(fields) = + serde_json::to_value(tuning).map_err(|e| e.to_string())? + { + values.extend(fields.into_iter().filter(|(_, v)| !v.is_null())); + } + Some(( + slot.1, + Payload::Tune { + key: key.clone(), + values, + }, + )) + } + _ => None, + }; + if let Some((key, value)) = entry { + if sync.local.len() >= 128 && !sync.local.contains_key(&(owner.into(), key.clone())) { + return Err("128 shared mod records maximum".into()); + } + sync.local.insert((owner.into(), key), value); + } + Ok(()) +} +fn synchronize(world: &mut World) { + let dt = world.resource::>().delta_secs(); + world.resource_scope(|world, mut sync: Mut| { + sync.elapsed += dt; + if sync.elapsed < 0.05 { + return; + } + sync.elapsed = 0.; + let (_, identity, _) = world + .resource::() + .mod_identity(); + if identity != sync.identity { + sync.published.clear(); + sync.identity = identity; + } + let packages: BTreeMap<_, _> = world + .resource::() + .manager + .packages + .iter() + .filter(|(_, p)| p.running()) + .map(|(id, p)| (id.clone(), (p.content_fingerprint(), p.root.clone()))) + .collect(); + sync.local + .retain(|(owner, _), _| packages.contains_key(owner)); + let mut outgoing = sync.local.clone(); + for (owner, key, state) in vehicles::network::capture(world) { + outgoing.insert((owner, format!("car/{key}")), Payload::Car { key, state }); + } + let mut published = BTreeSet::new(); + let mut failures = 0; + sync.states.clear(); + for ((owner, key), payload) in outgoing { + let Some((fingerprint, _)) = packages.get(&owner) else { + continue; + }; + if let Payload::State { key, value } = &payload { + if !value.is_null() { + sync.states + .entry(owner.clone()) + .or_default() + .entry(identity.to_string()) + .or_default() + .insert(key.clone(), value.clone()); + } + } + let key = wire_key(&owner, &key); + let bytes = serde_json::to_vec(&Record { + owner, + fingerprint: *fingerprint, + payload, + }) + .unwrap(); + if identity != 0 + && !world + .resource_mut::() + .publish_mod(&key, bytes) + { + failures += 1; + } + published.insert(key); + } + for key in sync.published.difference(&published) { + world + .resource_mut::() + .publish_mod(key, vec![]); + } + sync.published = published; + let records = world + .resource::() + .mod_records(); + let mut seen = BTreeSet::new(); + let mut mismatches = BTreeSet::new(); + for (peer, wire, seq, bytes) in records { + let Ok(record) = serde_json::from_slice::(&bytes) else { + continue; + }; + let Some((fingerprint, root)) = packages + .get(&record.owner) + .filter(|(f, _)| *f == record.fingerprint) + else { + mismatches.insert(record.owner); + continue; + }; + let _ = fingerprint; + let remote_owner = format!("@{peer}:{}", record.owner); + let record_id = (peer, wire.clone()); + let changed = sync + .remote + .get(&record_id) + .is_none_or(|(old, _, _)| *old != seq); + let mut object_key = String::new(); + let result = match record.payload { + Payload::State { key, value } => { + if key.len() > 64 || serde_json::to_vec(&value).unwrap().len() > 512 { + continue; + } + if !value.is_null() { + sync.states + .entry(record.owner.clone()) + .or_default() + .entry(peer.to_string()) + .or_default() + .insert(key, value); + } + Ok(()) + } + Payload::Cube { + key, + position, + size, + color, + } => { + object_key = format!("cube/{key}"); + let c = Command::Cube { + key, + position, + size, + color, + }; + if !c.validate() { + continue; + } + if changed { + world.resource_scope(|world, mut mods: Mut| { + super::apply_one(world, &mut mods, &remote_owner, c) + }) + } else { + Ok(()) + } + } + Payload::Car { key, state } => { + object_key = format!("car/{key}"); + if changed { + vehicles::network::receive(world, root, &remote_owner, &key, state) + } else { + Ok(()) + } + } + Payload::Tune { key, values } => { + let Ok(tuning) = serde_json::from_value(serde_json::to_value(values).unwrap()) + else { + continue; + }; + let c = Command::VehicleTune { key, tuning }; + if !c.validate() { + continue; + } + if changed { + vehicles::command(world, root, &remote_owner, c) + } else { + Ok(()) + } + } + }; + if result.is_ok() { + seen.insert(record_id.clone()); + sync.remote + .insert(record_id, (seq, remote_owner, object_key)); + } + } + let removed: Vec<_> = sync + .remote + .keys() + .filter(|id| !seen.contains(*id)) + .cloned() + .collect(); + for id in removed { + if let Some((_, owner, key)) = sync.remote.remove(&id) { + if let Some(key) = key.strip_prefix("cube/") { + world.resource_scope(|world, mut mods: Mut| { + super::retire(world, &mut mods, &(owner.clone(), key.into())) + }); + } + if let Some(key) = key.strip_prefix("car/") { + vehicles::network::remove(world, &owner, key); + } + } + } + // Remote lifecycle messages are observations, not local script commands. + world + .resource_mut::() + .events + .retain(|e| !e["owner"].as_str().is_some_and(|o| o.starts_with('@'))); + sync.status = if failures > 0 { + format!("Mod network limit reached: {failures} records") + } else if !mismatches.is_empty() { + format!( + "Mods missing, disabled or different: {}", + mismatches.into_iter().collect::>().join(", ") + ) + } else { + format!( + "Matching mods synchronized | {} remote records", + sync.remote.len() + ) + }; + }); +} + +pub(super) fn retire(world: &mut World, owner: &str) { + world + .resource_mut::() + .local + .retain(|(id, _), _| id != owner); +} +pub(super) fn clear(world: &mut World) { + let mut s = world.resource_mut::(); + s.local.clear(); + s.remote.clear(); + s.states.clear(); +} diff --git a/crates/skate-game/src/modding/vehicles/audio.rs b/crates/skate-game/src/modding/vehicles/audio.rs index a9f461a2..5ba2bf7a 100644 --- a/crates/skate-game/src/modding/vehicles/audio.rs +++ b/crates/skate-game/src/modding/vehicles/audio.rs @@ -1,49 +1,43 @@ -//! Original synthesized exhaust sound, shared by all vehicle mods that opt in. -use bevy::{audio::{AddAudioSource, Volume}, prelude::*}; +//! Shared synthesized engine voices; remote volume attenuates with distance. +use bevy::{audio::{AddAudioSource,Volume},prelude::*}; +use std::collections::{BTreeMap,BTreeSet}; use super::engine_sound::Engine; - -#[derive(Component)] -struct EngineVoice; -#[derive(Resource, Default)] -struct State { source: Option>, pitch: f32, volume: f32 } - -pub(super) fn install(app: &mut App) { - app.add_audio_source::().init_resource::() - .add_systems(Update, update.after(super::present)); -} - -fn update( - mut commands: Commands, vehicles: Res, - time: Res>, menu: Option>, - replay: Res, mut state: ResMut, - mut sources: ResMut>, mut voices: Query<(Entity, &mut AudioSink), With>, - pending: Query>, -) { - let car = vehicles.driver.as_ref().and_then(|d| vehicles.owned.get(&(d.owner.clone(), d.key.clone()))) - .and_then(|i| vehicles.simulation.vehicles.get(&i.id)) - .filter(|c| c.definition.engine_audio.enabled); - let active = crate::graphics_menu::gameplay_active(menu) && !replay.active; - let (pitch, volume) = car.map_or((0.7, 0.), |car| { - let a = &car.definition.engine_audio; - let speed = (car.controller.current_vehicle_speed.abs() / car.definition.max_speed).clamp(0., 1.); - let throttle = car.controls.throttle.abs(); - let revs = (speed * 0.65 + throttle * 0.35).clamp(0., 1.); - (a.idle_pitch + (a.max_pitch - a.idle_pitch) * revs, - if active { a.volume * (0.22 + 0.65 * throttle + 0.13 * speed) } else { 0. }) - }); - let dt = time.delta_secs().min(0.1); - state.pitch += (pitch - state.pitch) * (1. - (-7. * dt).exp()); - state.volume += (volume - state.volume) * (1. - (-12. * dt).exp()); - if car.is_some() && pending.is_empty() { - let source = state.source.get_or_insert_with(|| sources.add(Engine)).clone(); - commands.spawn((EngineVoice, AudioPlayer(source), PlaybackSettings::ONCE - .with_volume(Volume::Linear(0.)).with_speed(pitch))); - state.pitch = pitch; +#[derive(Component)] struct EngineVoice(u64); +#[derive(Resource,Default)] struct State {source:Option>,mix:BTreeMap} +pub(super) fn install(app:&mut App) {app.add_audio_source::().init_resource::().add_systems(Update,update.after(super::present));} +fn update(mut commands:Commands,vehicles:Res,time:Res>,menu:Option>, + replay:Res,skater:Res,mut state:ResMut,mut sources:ResMut>, + mut voices:Query<(Entity,&EngineVoice,&mut AudioSink)>,pending:Query<&EngineVoice>) { + let active=crate::graphics_menu::gameplay_active(menu) && !replay.active; + let listener=vehicles.player_pose().map(|p|Vec3::from_array(p.0)).unwrap_or_else(||crate::animation::native_matrix(skater.animated_skeleton.roots.animation_to_world).w_axis.truncate()); + let existing:BTreeSet<_>=pending.iter().map(|v|v.0).collect(); + let mut targets=BTreeMap::new(); + for (&id,car) in &vehicles.simulation.vehicles { + let a=&car.definition.engine_audio; + if !a.enabled || !super::network::occupied(&vehicles,id) {continue;} + let Some((p,_))=vehicles.simulation.pose(id) else {continue;}; + let distance=listener.distance(Vec3::from_array(p));if distance>80. {continue;} + let local=vehicles.driver.as_ref().and_then(|d|vehicles.owned.get(&(d.owner.clone(),d.key.clone()))).is_some_and(|i|i.id==id); + let attenuation=if local {1.} else {1./(1.+distance*distance/36.)}; + let speed=(car.controller.current_vehicle_speed.abs()/car.definition.max_speed).clamp(0.,1.); + let throttle=car.controls.throttle.abs();let revs=(speed*0.65+throttle*0.35).clamp(0.,1.); + let pitch=a.idle_pitch+(a.max_pitch-a.idle_pitch)*revs; + let volume=if active {a.volume*(0.22+0.65*throttle+0.13*speed)*attenuation} else {0.}; + targets.insert(id,(pitch,volume)); + if !existing.contains(&id) { + let source=state.source.get_or_insert_with(||sources.add(Engine)).clone(); + commands.spawn((EngineVoice(id),AudioPlayer(source),PlaybackSettings::ONCE.with_volume(Volume::Linear(0.)).with_speed(pitch))); + } + state.mix.entry(id).or_insert((pitch,0.)); } - for (entity, mut sink) in &mut voices { - sink.set_speed(state.pitch.max(0.25)); - sink.set_volume(Volume::Linear(state.volume)); - if car.is_none() && state.volume < 0.001 { commands.entity(entity).despawn(); } + let dt=time.delta_secs().min(0.1); + for (&id,(pitch,volume)) in &mut state.mix { + let (p,v)=targets.get(&id).copied().unwrap_or((*pitch,0.)); + *pitch+=(p-*pitch)*(1.-(-7.*dt).exp());*volume+=(v-*volume)*(1.-(-12.*dt).exp()); + } + for (entity,voice,mut sink) in &mut voices { + let (pitch,volume)=state.mix.get(&voice.0).copied().unwrap_or((1.,0.)); + sink.set_speed(pitch.max(0.25));sink.set_volume(Volume::Linear(volume)); + if !targets.contains_key(&voice.0) && volume<0.001 {commands.entity(entity).despawn();state.mix.remove(&voice.0);} } } - diff --git a/crates/skate-game/src/modding/vehicles/mod.rs b/crates/skate-game/src/modding/vehicles/mod.rs index 60eabf4e..381e0ec5 100644 --- a/crates/skate-game/src/modding/vehicles/mod.rs +++ b/crates/skate-game/src/modding/vehicles/mod.rs @@ -1,5 +1,6 @@ //! Native vehicle host: isolated Rapier world, mod ownership and driver lifecycle. mod animations; +pub(crate) mod network; mod audio; mod engine_sound; mod interpolation; @@ -20,6 +21,7 @@ struct Instance { scene: Handle, clips: animations::Clips, last_control: f32, + definition_path: String, } struct Driver { owner: String, @@ -32,12 +34,15 @@ struct Driver { #[derive(Resource, Default)] pub(crate) struct Vehicles { simulation: Simulation, + remote: BTreeMap, + skaters: BTreeMap<(u64,usize),skate_vehicles::rapier3d::prelude::RigidBodyHandle>, owned: BTreeMap<(String, String), Instance>, driver: Option, pub(super) events: Vec, clock: f32, hidden: Vec<(Entity, Visibility)>, pub(crate) pose: Option>, + pub(crate) network_pose: Option, last_visual: Vec<(Entity, Transform)>, blend_from: Vec<(Entity, Transform)>, visual_phase: String, @@ -85,7 +90,7 @@ pub(super) fn install(app: &mut App) { .add_systems( FixedUpdate, tick.after(super::fixed) - .run_if(crate::graphics_menu::gameplay_active), + .run_if(network::simulation_active), ) .add_systems( Update, @@ -203,6 +208,7 @@ pub(super) fn retire(world: &mut World, owner: &str) { v.simulation.remove(i.id); v.previous_motion.remove(&i.id); v.rendered_motion.remove(&i.id); + v.remote.remove(&i.id); world.despawn(i.entity); } } @@ -225,6 +231,9 @@ pub(super) fn clear(world: &mut World) { v.crash_handoff=false; v.previous_motion.clear(); v.rendered_motion.clear(); + v.remote.clear(); + v.skaters.clear(); + v.network_pose=None; v.simulation = Simulation::default(); v.events.clear(); }); @@ -278,7 +287,7 @@ pub(super) fn command( if v.owned.contains_key(&owned_key) { return Err("Vehicle key already spawned; remove it before respawning".into()); } - if v.owned.keys().filter(|(o, _)| o == owner).count() >= 8 || v.owned.len() >= 32 { + if v.owned.keys().filter(|(o, _)| o == owner).count() >= 8 || v.owned.keys().filter(|(o,_)|o.starts_with('@')==owner.starts_with('@')).count() >= if owner.starts_with('@') {288} else {32} { return Err("Vehicle limit: 8 per mod, 32 total".into()); } let bytes = skate_mods::read_bounded(root, &definition, 128 * 1024)?; @@ -337,6 +346,7 @@ pub(super) fn command( scene, clips, last_control: clock, + definition_path: definition, }, ); event(&mut v, owner, &key, "vehicle_spawned"); @@ -352,6 +362,7 @@ pub(super) fn command( v.simulation.remove(i.id); v.previous_motion.remove(&i.id); v.rendered_motion.remove(&i.id); + v.remote.remove(&i.id); world.despawn(i.entity); event(&mut v, owner, &key, "vehicle_removed"); } @@ -476,6 +487,8 @@ fn tick(world: &mut World) { let dt = world.resource::>().delta_secs(); world.resource_scope(|world, mut v: Mut| { v.clock += dt; + network::skater_proxies(world,&mut v); + network::advance(&mut v, dt); let clock = v.clock; let parked: Vec<_> = v .owned @@ -506,10 +519,10 @@ fn tick(world: &mut World) { } let occupied_id=v.driver.as_ref().and_then(|d|v.owned.get(&(d.owner.clone(),d.key.clone()))).map(|i|i.id); let ids:Vec<_>=v.simulation.vehicles.keys().copied().collect(); - for id in ids {v.simulation.set_occupied(id,Some(id)==occupied_id);} + for id in ids { if !v.remote.contains_key(&id) { v.simulation.set_occupied(id,Some(id)==occupied_id); }} if !v.owned.is_empty() { v.previous_motion = v.simulation.vehicles.keys().filter_map(|&id| - interpolation::Motion::capture(&v.simulation, id).map(|m| (id, m))).collect(); + network::motion(&v, id).map(|m| (id, m))).collect(); v.simulation.step(dt); } if let Some(id)=occupied_id { @@ -583,7 +596,7 @@ pub(crate) fn present(world: &mut World) { } // Chassis, wheels, rider and camera share one fixed-step render sample. v.rendered_motion = v.simulation.vehicles.keys().filter_map(|&id| { - let current = interpolation::Motion::capture(&v.simulation, id)?; + let current = network::motion(&v, id)?; let sample = v.previous_motion.get(&id).map_or_else(|| current.clone(), |previous| previous.sample(¤t, alpha)); Some((id, sample)) @@ -719,6 +732,7 @@ pub(crate) fn present(world: &mut World) { crate::animation::blend_vehicle_visual(world,&v.blend_from,t*t*(3.-2.*t)); } else {v.blend_from.clear();v.crash_handoff=false;} v.last_visual=crate::animation::capture_vehicle_visual(world); + v.network_pose=if v.driver.is_some() || !v.blend_from.is_empty() {Some(crate::animation::network_visual(world))} else {None}; }); } diff --git a/crates/skate-game/src/modding/vehicles/network.rs b/crates/skate-game/src/modding/vehicles/network.rs new file mode 100644 index 00000000..ee64e435 --- /dev/null +++ b/crates/skate-game/src/modding/vehicles/network.rs @@ -0,0 +1,351 @@ +//! Owner-simulated vehicles, matching the native player's multiplayer ownership. +use super::*; +use serde::{Deserialize, Serialize}; +use skate_vehicles::rapier3d::prelude::{RigidBodyType, Rotation, Vector}; +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Car { + pub definition: String, + pub position: [f32; 3], + pub rotation: [f32; 4], + pub velocity: [f32; 3], + pub angular: [f32; 3], + pub controls: Controls, + pub occupied: bool, + pub rider: Option<([f32; 3], [f32; 4])>, + pub wheels: Vec<[f32; 3]>, +} +pub(super) struct Target { + state: Car, + age: f32, +} +pub(super) fn simulation_active( + menu: Option>, + net: Option>, +) -> bool { + menu.is_none_or(|m| !m.open) || net.is_some_and(|n| n.active()) +} +pub(crate) fn capture(world: &World) -> Vec<(String, String, Car)> { + let v = world.resource::(); + v.owned + .iter() + .filter(|(_, i)| !v.remote.contains_key(&i.id)) + .filter_map(|((owner, key), i)| { + let car = v.simulation.vehicles.get(&i.id)?; + let body = &v.simulation.world.bodies[car.body]; + Some(( + owner.clone(), + key.clone(), + Car { + definition: i.definition_path.clone(), + position: body.translation().to_array(), + rotation: body.rotation().to_array(), + velocity: body.linvel().to_array(), + angular: body.angvel().to_array(), + controls: car.controls, + rider: v + .network_pose + .as_ref() + .filter(|_| { + v.driver + .as_ref() + .is_some_and(|d| d.owner == *owner && d.key == *key) + }) + .map(|pose| { + let body = Mat4::from_rotation_translation( + Quat::from_array(body.rotation().to_array()), + Vec3::from_array(body.translation().to_array()), + ); + let t = Transform::from_matrix( + body.inverse() * crate::physics::network::matrix(pose.root), + ); + (t.translation.to_array(), t.rotation.to_array()) + }), + occupied: v + .driver + .as_ref() + .is_some_and(|d| d.owner == *owner && d.key == *key), + wheels: car + .controller + .wheels() + .iter() + .map(|w| [w.steering, w.rotation, w.raycast_info().suspension_length]) + .collect(), + }, + )) + }) + .collect() +} +pub(crate) fn receive( + world: &mut World, + root: &std::path::Path, + owner: &str, + key: &str, + state: Car, +) -> Result<(), String> { + if let Some((p, q)) = state.rider { + let q = Quat::from_array(q); + if p.iter().any(|x| !x.is_finite() || x.abs() > 20.) + || !q.is_finite() + || (q.length() - 1.).abs() > 0.02 + { + return Err("Invalid rider pose".into()); + } + } + let spawn = Command::VehicleSpawn { + key: key.into(), + definition: state.definition.clone(), + position: state.position, + heading: 0., + }; + let q = Quat::from_array(state.rotation); + if !spawn.validate() + || !state.controls.valid() + || !q.is_finite() + || (q.length() - 1.).abs() > 0.02 + || state + .velocity + .iter() + .chain(&state.angular) + .any(|x| !x.is_finite() || x.abs() > 200.) + || state.wheels.len() > 16 + || state + .wheels + .iter() + .flatten() + .any(|x| !x.is_finite() || x.abs() > 100000.) + { + return Err("Invalid remote vehicle".into()); + } + let owned = (owner.into(), key.into()); + let replace = world + .resource::() + .owned + .get(&owned) + .is_some_and(|i| i.definition_path != state.definition); + if replace { + remove(world, owner, key); + } + if !world.resource::().owned.contains_key(&owned) { + super::command(world, root, owner, spawn)?; + } + let mut v = world.resource_mut::(); + let id = v.owned[&owned].id; + let car = v.simulation.vehicles.get_mut(&id).unwrap(); + car.remote = true; + car.controls = state.controls; + let handle = car.body; + let body = &mut v.simulation.world.bodies[handle]; + body.set_body_type(RigidBodyType::KinematicVelocityBased, true); + if !v.remote.contains_key(&id) { + let body = &mut v.simulation.world.bodies[handle]; + body.set_translation(Vector::from_array(state.position), true); + body.set_rotation( + Rotation::from_xyzw( + state.rotation[0], + state.rotation[1], + state.rotation[2], + state.rotation[3], + ), + true, + ); + } + v.simulation.set_occupied(id, state.occupied); + v.remote.insert(id, Target { state, age: 0. }); + Ok(()) +} +pub(crate) fn remove(world: &mut World, owner: &str, key: &str) { + // Removal has no asset access or effects on the locally occupied vehicle. + let _ = super::command( + world, + std::path::Path::new(""), + owner, + Command::VehicleRemove { key: key.into() }, + ); +} +pub(super) fn advance(v: &mut Vehicles, dt: f32) { + for (&id, target) in &mut v.remote { + target.age += dt; + let Some(car) = v.simulation.vehicles.get_mut(&id) else { + continue; + }; + let body = &mut v.simulation.world.bodies[car.body]; + let source = Vec3::from_array(target.state.position) + + Vec3::from_array(target.state.velocity) * target.age.min(0.15); + let mut error = source - Vec3::from_array(body.translation().to_array()); + if error.length() > 5. { + body.set_translation(Vector::from_array(source.to_array()), true); + error = Vec3::ZERO; + } + let velocity = if target.age < 0.15 { + Vec3::from_array(target.state.velocity) + } else { + Vec3::ZERO + }; + body.set_linvel( + Vector::from_array( + (velocity + (error * 10.).clamp_length_max(5.)) + .clamp_length_max(100.) + .to_array(), + ), + true, + ); + let current = Quat::from_array(body.rotation().to_array()); + let desired = Quat::from_array(target.state.rotation); + let delta = (desired * current.inverse()).normalize(); + let delta = if delta.w < 0. { -delta } else { delta }; + let angular = if target.age < 0.15 { + Vec3::from_array(target.state.angular) + } else { + Vec3::ZERO + }; + body.set_angvel( + Vector::from_array( + (angular + delta.to_scaled_axis() * 10.) + .clamp_length_max(20.) + .to_array(), + ), + true, + ); + car.controls = target.state.controls; + car.controller.current_vehicle_speed = velocity.dot(current * Vec3::Z); + for (wheel, values) in car + .controller + .wheels_mut() + .iter_mut() + .zip(&target.state.wheels) + { + wheel.steering = values[0]; + wheel.rotation = values[1]; + } + } +} + +pub(crate) fn attached_root(v: &Vehicles, peer: u64) -> Option { + let prefix = format!("@{peer}:"); + v.owned + .iter() + .filter(|((owner, _), _)| owner.starts_with(&prefix)) + .find_map(|(_, i)| { + let target = v.remote.get(&i.id)?; + if !target.state.occupied { + return None; + } + let (p, q) = target.state.rider?; + let body = v.rendered_motion.get(&i.id)?.body; + Some(Transform::from_matrix( + body.to_matrix() + * Mat4::from_rotation_translation(Quat::from_array(q), Vec3::from_array(p)), + )) + }) +} + +pub(crate) struct CollisionShape { + pub position: [f32; 3], + pub rotation: [f32; 4], + pub velocity: [f32; 3], + pub angular: [f32; 3], + pub half: [f32; 3], + pub offset: [f32; 3], + pub rounding: f32, + pub mass: f32, +} +pub(crate) fn collision_shapes(v: &Vehicles) -> Vec { + v.simulation + .vehicles + .values() + .map(|car| { + let b = &v.simulation.world.bodies[car.body]; + let d = &car.definition; + CollisionShape { + position: b.translation().to_array(), + rotation: b.rotation().to_array(), + velocity: b.linvel().to_array(), + angular: b.angvel().to_array(), + half: d.half_extents, + offset: d.collider_offset, + rounding: d.collider_rounding, + mass: d.mass, + } + }) + .collect() +} +pub(super) fn skater_proxies(world: &World, v: &mut Vehicles) { + use skate_vehicles::rapier3d::prelude::{ColliderBuilder, RigidBodyBuilder}; + let mut frames = world + .resource::() + .collision_players(); + if !v.occupied() { + frames.push(( + 0, + crate::physics::network::capture_body( + world.resource::(), + world.resource::(), + ), + )); + } + let mut live = std::collections::BTreeSet::new(); + for (peer, frame) in frames { + for (index, body) in frame.bodies.iter().enumerate() { + if frame.enabled & (1u64 << index) == 0 { + continue; + } + // Only near a car: bounded spheres approximate the native articulated collision parts. + if !v.simulation.vehicles.values().any(|c| { + Vec3::from_array(v.simulation.world.bodies[c.body].translation().to_array()) + .distance_squared(Vec3::from_array(body.pose.p)) + < 100. + }) { + continue; + } + let key = (peer, index); + live.insert(key); + let handle = *v.skaters.entry(key).or_insert_with(|| { + v.simulation + .world + .insert( + RigidBodyBuilder::kinematic_velocity_based(), + ColliderBuilder::ball(if index < 7 { 0.08 } else { 0.14 }).friction(0.3), + ) + .0 + }); + let proxy = &mut v.simulation.world.bodies[handle]; + proxy.set_translation(Vector::from_array(body.pose.p), true); + proxy.set_linvel(Vector::from_array(body.velocity), true); + } + } + let removed: Vec<_> = v + .skaters + .keys() + .filter(|key| !live.contains(key)) + .copied() + .collect(); + for key in removed { + if let Some(body) = v.skaters.remove(&key) { + v.simulation.world.remove_body(body); + } + } +} + +pub(super) fn occupied(v: &Vehicles, id: u64) -> bool { + v.remote.get(&id).map_or_else( + || { + v.driver + .as_ref() + .and_then(|d| v.owned.get(&(d.owner.clone(), d.key.clone()))) + .is_some_and(|i| i.id == id) + }, + |t| t.state.occupied, + ) +} + +pub(super) fn motion(v: &Vehicles, id: u64) -> Option { + let mut m = interpolation::Motion::capture(&v.simulation, id)?; + if let Some(target) = v.remote.get(&id) { + let d = &v.simulation.vehicles[&id].definition; + for (wheel, values) in m.wheels.iter_mut().zip(&target.state.wheels) { + wheel.translation.y = (d.suspension_length - values[2]) / d.model_scale; + } + } + Some(m) +} diff --git a/crates/skate-game/src/multiplayer.rs b/crates/skate-game/src/multiplayer.rs index d0f1b285..0d200e0b 100644 --- a/crates/skate-game/src/multiplayer.rs +++ b/crates/skate-game/src/multiplayer.rs @@ -87,6 +87,20 @@ pub(crate) struct Multiplayer { pub browser_status: String, } impl Multiplayer { + pub(crate) fn mod_identity(&self) -> (bool, u64, bool) { + self.lobby.as_ref().map_or((false, 0, true), |l| (true, l.local, l.is_host())) + } + pub(crate) fn collision_players(&self) -> Vec<(u64,skate_net::packed::BodyState)> { + self.remotes.iter().filter(|(_,r)|r.body_at.elapsed() Vec<(u64, String, u32, Vec)> { + self.lobby.as_ref().map_or_else(Vec::new, |l| l.actors.iter().filter(|(id, _)| **id != l.local) + .flat_map(|(&id, actor)| actor.application.iter().map(move |(key,r)| (id,key.clone(),r.seq,r.value.clone()))).collect()) + } + pub(crate) fn publish_mod(&mut self, key: &str, value: Vec) -> bool { + let now = self.started.elapsed().as_millis() as u64; + self.lobby.as_mut().is_some_and(|l| l.publish_application(key,value,now)) + } pub fn active(&self) -> bool { self.lobby.is_some() } @@ -315,7 +329,7 @@ impl Plugin for MultiplayerPlugin { .add_systems(PreUpdate, (world_changed, receive).chain().after(crate::map_transition::MapTransitionSet)) .add_systems(Startup, setup_hud) .add_systems(Update, hud) - .add_systems(Update, send_pose.after(FrameSet::Animation)) + .add_systems(Update, send_pose.after(crate::modding::vehicles::present)) .add_systems( FixedUpdate, prepare @@ -569,7 +583,7 @@ fn receive(mut net: ResMut) { net.last_metrics = Instant::now(); } } -fn prepare(net: Res, mut physics: ResMut, skater: Res) { +fn prepare(net: Res, mut physics: ResMut, skater: Res, vehicles: Res) { physics.network_active = net.active(); physics.network_contacts = 0; let mut proxies = std::mem::take(&mut physics.network_proxies); @@ -586,6 +600,9 @@ fn prepare(net: Res, mut physics: ResMut, skater: Res< ); } } + for shape in crate::modding::vehicles::network::collision_shapes(&vehicles) { + proxies.append_vehicle(&shape,&physics,&skater); + } physics.network_proxies = proxies; } #[derive(Component)] @@ -614,39 +631,45 @@ fn hud( net: Res, physics: Res, mut label: Single<&mut Text, With>, + mods: Res, ) { ***label = if net.active() { format!( - "{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer", + "{}\n{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer", net.status, net.rates, net.provider_metrics, net.visual_status, + mods.status, physics.network_contacts ) } else { String::new() }; } -fn send(mut net: ResMut, physics: Res, skater: Res) { +fn send(mut net: ResMut, physics: Res, skater: Res, vehicles: 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)) { + let mut state=network::capture_body(&physics,&skater); + if let Some(pose)=&vehicles.network_pose {state.root=pose.root;} + if vehicles.occupied() {state.enabled=1u64<<62;} + if let Some(p) = Packed::body(&state) { net.lobby.as_mut().unwrap().publish(packed::BODY, p, now); } net.last_body = Instant::now(); } } -fn send_pose(mut net: ResMut, skater: Res) { +fn send_pose(mut net: ResMut, skater: Res, vehicles: 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)) { + let pose=vehicles.network_pose.clone().unwrap_or_else(||network::capture_pose(&skater,&net.anchors)); + if let Some(p) = Packed::pose(&pose) { 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 index afa29175..701b9c1d 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -7,6 +7,7 @@ struct RemoteSkins { actors: BTreeMap, rest: Vec, parents: Vec, + board: Option, } #[derive(Default)] struct RemoteSkin { @@ -58,7 +59,9 @@ impl Plugin for RemoteRenderPlugin { .iter() .map(|&p| p as i32) .collect(); + let board=skater.animation.evaluator.frames.bone_names.iter().position(|n|n=="SKATEBOARD_ROOT"); app.insert_resource(RemoteSkins { + board, reported: 0., actors: BTreeMap::new(), rest, @@ -66,7 +69,7 @@ impl Plugin for RemoteRenderPlugin { }) .add_systems( Update, - (spawn, bind, present).chain().after(FrameSet::Animation), + (spawn, bind, present).chain().after(crate::modding::vehicles::present), ); } } @@ -188,6 +191,7 @@ fn globals(bones: &[skate_net::Bone], skin: &RemoteSkins) -> Vec { } fn present( mut net: ResMut, + vehicles: Res, mut skins: ResMut, mut nodes: Query<&mut Transform>, ) { @@ -272,6 +276,11 @@ fn present( } } } + if let Some(root)=skin.root { + if let Some(attached)=crate::modding::vehicles::network::attached_root(&vehicles,id) {if let Ok(mut t)=nodes.get_mut(root) {*t=attached;}} + } + let seated=remote.body.enabled & (1u64<<62)!=0; + for &(entity,i,_) in &skin.bindings {if skins.board==Some(i) {if let Ok(mut t)=nodes.get_mut(entity) {t.scale=Vec3::splat(if seated {0.001} else {1.});}}} skins.actors.insert(id, skin); } if now < skins.reported || now - skins.reported >= 1. { diff --git a/crates/skate-game/src/physics/network.rs b/crates/skate-game/src/physics/network.rs index b61b7a48..5385022d 100644 --- a/crates/skate-game/src/physics/network.rs +++ b/crates/skate-game/src/physics/network.rs @@ -336,3 +336,26 @@ impl Proxies { } } } + +impl Proxies { + /// Vehicle chassis join the existing native contact solver; reactions remain local. + pub fn append_vehicle(&mut self,shape:&crate::modding::vehicles::network::CollisionShape,physics:&GamePhysics,skater:&SkaterRuntime) { + let p=Vec3::from_array(shape.position); + if !physics.board.bodies().iter().chain(skater.skeleton.bodies()).any(|b|Vec3::from_array(xyz(b.rates.position)).distance_squared(p)<100.) {return;} + let base=skater.skeleton.bodies().len()+skater.skeleton_drives.targets.bodies.len()+self.bodies.len(); + let mut body=physics.board.bodies()[0]; + let q=Quat::from_array(shape.rotation).normalize(); + body.rates.position=vector(shape.position); + body.rates.orientation=RetailQuaternion{x:q.x,y:q.y,z:q.z,w:q.w}; + body.rates.basis=skate_core::math::Basis3{columns:Mat3::from_quat(q).to_cols_array_2d()}; + body.inertia.inverse_mass=1./shape.mass; + let [x,y,z]=shape.half; body.inertia.inverse_tensor=vector([3./(shape.mass*(y*y+z*z)),3./(shape.mass*(x*x+z*z)),3./(shape.mass*(x*x+y*y))]); + body.rates.world_inverse_inertia=world_inverse_inertia(body.rates.basis,body.inertia.inverse_tensor); + body.rates.linear_velocity=vector(shape.velocity);body.rates.angular_velocity=vector(shape.angular); + body.rates.force_acceleration=Vector3::ZERO;body.rates.torque_acceleration=Vector3::ZERO;body.state_flags=4; + self.bodies.push(body); + self.volumes.push(BoardWorldVolume{body:CollisionBody::Attached(base),primitive:ContactPrimitive::RoundedBox{ + center:vector((p+q*Vec3::from_array(shape.offset)).to_array()),basis:body.rates.basis, + half_extents:vector(shape.half.map(|x|x-shape.rounding)),radius:shape.rounding},linear_velocity:body.rates.linear_velocity,material:skate_core::physics::contact::RetailContactMaterial{static_friction:0.4,dynamic_friction:0.3,restitution:0.1}}); + } +} diff --git a/crates/skate-mods/src/api.lua b/crates/skate-mods/src/api.lua index 011f9c10..314373d9 100644 --- a/crates/skate-mods/src/api.lua +++ b/crates/skate-mods/src/api.lua @@ -63,3 +63,13 @@ end function sdk.vehicle.tune(key,tuning) submit{kind="vehicle_tune",key=key,tuning=tuning} end function sdk.vehicle.input() return sdk.snapshot.vehicle_input end + +-- Owner-scoped latest state: changes replicate, callbacks never execute remotely. +sdk.net = {} +function sdk.net.info() return sdk.snapshot.network or {active=false,local_id="0",is_host=true} end +function sdk.net.publish(key,value) submit{kind="network_state",key=key,value=value} end +function sdk.net.read(peer,key) + local n = sdk.snapshot.network or {} + local state = ((n.states or {})[sdk.mod_id] or {})[tostring(peer)] or {} + return state[key] +end diff --git a/crates/skate-mods/src/lib.rs b/crates/skate-mods/src/lib.rs index fb199486..a25cb6f2 100644 --- a/crates/skate-mods/src/lib.rs +++ b/crates/skate-mods/src/lib.rs @@ -49,6 +49,7 @@ pub struct Package { pending: Option<(u64, Instant)>, } impl Package { + pub fn content_fingerprint(&self) -> u64 { self.fingerprint } pub fn running(&self) -> bool { self.vm.is_some() } @@ -433,7 +434,7 @@ fn fingerprint(root: &Path) -> Result { if *bytes > 64 * 1024 * 1024 { return Err("Package exceeds 64 MiB".into()); } - p.hash(h); + p.strip_prefix(root).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/").hash(h); std::fs::read(&p).map_err(|e| e.to_string())?.hash(h); } } diff --git a/crates/skate-mods/src/vm.rs b/crates/skate-mods/src/vm.rs index 72e6b77d..36864e2a 100644 --- a/crates/skate-mods/src/vm.rs +++ b/crates/skate-mods/src/vm.rs @@ -13,6 +13,7 @@ use std::{ #[derive(Clone, Debug, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Command { + NetworkState { key: String, #[serde(default)] value: Value }, VehicleTune { key:String, tuning:skate_vehicles::VehicleTuning }, VehicleSpawn { key:String, definition:String, position:[f32;3], heading:f32 }, VehicleRemove { key:String }, VehicleEnter { key:String }, VehicleExit { key:String }, @@ -47,9 +48,10 @@ pub enum Command { }, } impl Command { - fn validate(&self) -> bool { + pub fn validate(&self) -> bool { let point = |p: &[f32; 3]| p.iter().all(|v| v.is_finite() && v.abs() <= 100_000.); match self { + Self::NetworkState { key, value } => crate::schema::valid_id(key) && serde_json::to_vec(value).is_ok_and(|v| v.len() <= 512), Self::VehicleTune{key,tuning} => crate::schema::valid_id(key) && tuning.valid(), Self::VehicleSpawn{key,definition,position,heading} => crate::schema::valid_id(key) && skate_vehicles::package_path(definition) && point(position) && heading.is_finite(), Self::VehicleReset{key,position,heading} => crate::schema::valid_id(key) && point(position) && heading.is_finite(), diff --git a/crates/skate-mods/tests/runtime.rs b/crates/skate-mods/tests/runtime.rs index 0ebd0b33..dd6899a4 100644 --- a/crates/skate-mods/tests/runtime.rs +++ b/crates/skate-mods/tests/runtime.rs @@ -440,3 +440,37 @@ fn kart_controller_reset_is_edge_triggered_and_rebindable() { assert_eq!(m.commands.iter().filter(|(_,c)|matches!(c,Command::VehicleReset{..})).count(),1); assert!(m.packages["community.mario-kart"].running()); } + +#[test] +fn fingerprints_are_portable_and_include_assets() { + let a=Fixture::new("return {}");let b=Fixture::new("return {}"); + std::fs::write(a.0.join("mods/example/asset.txt"),"same").unwrap(); + std::fs::write(b.0.join("mods/example/asset.txt"),"same").unwrap(); + let first=a.manager().packages["example"].content_fingerprint(); + assert_eq!(first,b.manager().packages["example"].content_fingerprint()); + std::fs::write(b.0.join("mods/example/asset.txt"),"changed").unwrap(); + assert_ne!(first,b.manager().packages["example"].content_fingerprint()); +} +#[test] +fn shared_state_api_is_owner_scoped_and_transactional() { + let f=Fixture::new(r#"return {on_update=function() + assert(sdk.net.info().active) + assert(sdk.net.read("2","score")==7) + sdk.net.publish("score",8) + end}"#); + let mut m=f.manager();m.snapshot=json!({"network":{"active":true,"states":{"example":{"2":{"score":7}}}}}); + m.enable("example",true).unwrap();m.dispatch("on_update",json!({"dt":0.1})); + assert!(matches!(&m.commands[0].1,Command::NetworkState{key,value} if key=="score" && value==8)); + let f=Fixture::new(r#"return {on_update=function() sdk.net.publish("a",1); error("abort") end}"#); + let mut m=f.manager();m.enable("example",true).unwrap();m.dispatch("on_update",json!({"dt":0.1})); + assert!(m.commands.is_empty()); +} + +#[test] +fn network_nil_clears_and_oversized_values_fail() { + let f=Fixture::new(r#"return {on_load=function() sdk.net.publish("score",nil) end}"#); + let mut m=f.manager();m.enable("example",true).unwrap(); + assert!(matches!(&m.commands[0].1,Command::NetworkState{value,..} if value.is_null())); + let f=Fixture::new(r#"return {on_load=function() sdk.net.publish("score",string.rep("x",513)) end}"#); + let mut m=f.manager();m.enable("example",true).unwrap();assert!(m.packages["example"].error.is_some());assert!(m.commands.is_empty()); +} diff --git a/crates/skate-net/src/directory.rs b/crates/skate-net/src/directory.rs index 1c253c7f..8d8d22aa 100644 --- a/crates/skate-net/src/directory.rs +++ b/crates/skate-net/src/directory.rs @@ -1,6 +1,6 @@ //! 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 NAMESPACE: &str = "skate3rust-free-skate-v5"; pub const PAGE_SIZE: usize = 5; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Request { diff --git a/crates/skate-net/src/lobby.rs b/crates/skate-net/src/lobby.rs index 8122847d..e7d19ff4 100644 --- a/crates/skate-net/src/lobby.rs +++ b/crates/skate-net/src/lobby.rs @@ -10,6 +10,13 @@ pub const PING: u8 = 6; pub const PONG: u8 = 7; pub const REJECT: u8 = 8; pub const GOODBYE: u8 = 9; +pub const APPLICATION: u8 = 10; +pub const APPLICATION_ACK: u8 = 11; +pub const MAX_APP_KEYS: usize = 256; +pub const MAX_APP_VALUE: usize = 1024; +#[derive(Clone, Debug)] +pub struct Application { pub seq: u32, pub value: Vec, pub received: u64 } + pub const DEFAULT_LINK_BUDGET: f64 = 180_000.; pub const DEFAULT_HOST_BUDGET: f64 = 1_000_000.; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -64,6 +71,7 @@ pub struct Actor { pub info: Info, pub body: Stream, pub pose: Stream, + pub application: BTreeMap, } impl Actor { fn new(info: Info) -> Self { @@ -71,6 +79,7 @@ impl Actor { info, body: Stream::default(), pose: Stream::default(), + application: BTreeMap::new(), } } pub fn stream(&self, kind: u8) -> &Stream { @@ -91,6 +100,9 @@ struct Link { sent: BTreeMap<(u64, u8), (u64, u32)>, keys: BTreeMap<(u64, u8), u64>, credits: f64, + app_sent: BTreeMap<(u64, String), (u64, u32)>, + app_acks: BTreeMap<(u64,String),u32>, + app_round: usize, last_ack: u64, last_ping: u64, rtt: u64, @@ -104,6 +116,7 @@ impl Link { sent: BTreeMap::new(), keys: BTreeMap::new(), credits: 2400., + app_sent: BTreeMap::new(), app_acks: BTreeMap::new(), app_round: 0, last_ack: 0, last_ping: 0, rtt: 0, @@ -230,6 +243,17 @@ impl Session { }); stream.push(seq, state, now); } + /// Latest owner-authenticated state. Empty values are persistent tombstones. + /// Repeated full records recover loss and initialize late joiners without replaying Lua. + pub fn publish_application(&mut self, key: &str, value: Vec, now: u64) -> bool { + if key.is_empty() || key.len() > 128 || value.len() > MAX_APP_VALUE { return false; } + let records = &mut self.actors.get_mut(&self.local).unwrap().application; + if records.len() >= MAX_APP_KEYS && !records.contains_key(key) { return false; } + if records.get(key).is_some_and(|r| r.value == value) { return true; } + let seq = records.get(key).map_or(1, |r| r.seq.saturating_add(1)); + records.insert(key.into(), Application {seq, value, received: now}); + true + } pub fn record_send(&mut self, len: usize, success: bool) { if success { self.stats.tx_bytes += len as u64; @@ -355,7 +379,33 @@ impl Session { .into(); return; } - if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE) { + if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE | APPLICATION) { + return; + } + if kind == APPLICATION_ACK { + let Some(origin)=r.u64() else {return;}; + let Ok(key)=std::str::from_utf8(r.0) else {return;}; + if self.actors.get(&origin).and_then(|a|a.application.get(key)).is_some_and(|record|seq<=record.seq) { + let link=self.links.get_mut(&peer).unwrap(); + let ack=link.app_acks.entry((origin,key.into())).or_default();*ack=(*ack).max(seq);link.seen=now; + } + return; + } + if kind == APPLICATION { + if actor == self.local || (self.is_host() && self.links[&peer].actor != actor) { return; } + let Some(a) = self.actors.get_mut(&actor) else { return; }; + let Some(length) = r.byte() else { return; }; + let length = length as usize; + if length == 0 || length > 128 || r.0.len() < length || r.0.len() - length > MAX_APP_VALUE { return; } + let Ok(key) = std::str::from_utf8(&r.0[..length]) else { return; }; + if a.application.len() >= MAX_APP_KEYS && !a.application.contains_key(key) { return; } + if a.application.get(key).is_none_or(|old| seq > old.seq) { + a.application.insert(key.into(), Application { seq, value: r.0[length..].to_vec(), received: now }); + } + let latest=a.application.get(key).map_or(seq,|r|r.seq); + let mut ack=packed::header(self.session,self.local,APPLICATION_ACK,latest); + ack.extend(actor.to_le_bytes());ack.extend(key.as_bytes());self.queue(peer,ack); + self.links.get_mut(&peer).unwrap().seen = now; return; } if kind == GOODBYE { @@ -622,6 +672,31 @@ impl Session { } } } + // Rotate every record, not just actors, so a busy mod cannot starve later keys. + let mut app_peers:Vec<_>=self.links.keys().copied().collect(); + if !app_peers.is_empty() {let offset=self.round%app_peers.len();app_peers.rotate_left(offset);} + for peer in app_peers { + let link=self.links.get_mut(&peer).unwrap(); + link.app_sent.retain(|(id, _), _| self.actors.contains_key(id)); + link.app_acks.retain(|(id,_),_|self.actors.contains_key(id)); + let records: Vec<_> = self.actors.iter().filter(|(id, _)| **id != link.actor && (self.host.is_none() || **id == self.local)) + .flat_map(|(&id, a)| a.application.iter().map(move |(key, record)| (id, key, record))).collect(); + let count = records.len(); + for offset in 0..count { + let index = (link.app_round + offset) % count; + let (id, key, record) = records[index]; + if link.app_acks.get(&(id,key.clone())).is_some_and(|&seq|seq==record.seq) {continue;} + let previous = link.app_sent.get(&(id, key.clone())); + if previous.is_some_and(|&(at, seq)| now.saturating_sub(at) < if seq == record.seq { 110 + ((self.round as u64 + id) * 17) % 130 } else { 50 }) { continue; } + let mut data = packed::header(self.session, id, APPLICATION, record.seq); + data.push(key.len() as u8); data.extend(key.as_bytes()); data.extend(&record.value); + let bytes = data.len() as f64; + if bytes > link.credits || bytes > self.credits { link.app_round = index; break; } + link.credits -= bytes; self.credits -= bytes; + link.app_sent.insert((id, key.clone()), (now, record.seq)); + output.push(Outgoing {peer, data}); + } + } self.stats.rtt_ms = self.links.values().map(|l| l.rtt).max().unwrap_or(0); output } diff --git a/crates/skate-net/src/packed.rs b/crates/skate-net/src/packed.rs index 7638c100..4600cda0 100644 --- a/crates/skate-net/src/packed.rs +++ b/crates/skate-net/src/packed.rs @@ -1,10 +1,10 @@ -//! Version 4: one datagram per independently decodable stream update. +//! Version 5: 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 MAGIC: &[u8; 8] = b"SK8NET05"; pub const HEADER: usize = 29; pub const BODY: u8 = 1; pub const POSE: u8 = 2; @@ -167,7 +167,7 @@ impl Packed { 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 { + if !s.root.valid() || s.bodies.len() != 33 || (s.enabled & !((1 << 63) | (1 << 62))) >> 33 != 0 { return None; } let mut rows = Vec::with_capacity(33); @@ -222,7 +222,7 @@ impl Packed { 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 { + if self.rows.len() != 33 || (self.enabled & !((1 << 63) | (1 << 62))) >> 33 != 0 { return None; } for row in &self.rows { diff --git a/crates/skate-net/tests/mods.rs b/crates/skate-net/tests/mods.rs new file mode 100644 index 00000000..5fa0de79 --- /dev/null +++ b/crates/skate-net/tests/mods.rs @@ -0,0 +1,119 @@ +use skate_net::{ + lobby::{APPLICATION, Info, MAX_APP_KEYS, Session}, + packed, +}; +fn info(id: u64) -> Info { + Info { + id, + map: 1, + rig: 2, + physics: 3, + appearance: 4, + } +} +fn pump(host: &mut Session, guests: &mut [Session], now: u64, loss: bool) { + for (i, g) in guests.iter_mut().enumerate() { + for (n, p) in g.service(now).into_iter().enumerate() { + if !loss || (now / 50 + n as u64) % 3 != 0 { + host.receive(i as u64 + 10, &p.data, now); + } + } + } + for (n, p) in host.service(now).into_iter().enumerate() { + assert!(p.data.len() <= packed::MTU); + if !loss || (now / 50 + n as u64) % 4 != 0 { + if let Some(g) = guests.get_mut((p.peer - 10) as usize) { + g.receive(1, &p.data, now); + } + } + } +} +#[test] +fn owner_state_recovers_loss_late_join_and_tombstones() { + let mut h = Session::new(7, info(1), None); + let mut g = vec![Session::new(7, info(2), Some(1))]; + assert!(g[0].publish_application("kart", b"spawned".to_vec(), 0)); + for t in (0..2000).step_by(50) { + pump(&mut h, &mut g, t, true); + } + assert_eq!(h.actors[&2].application["kart"].value, b"spawned"); + g.push(Session::new(7, info(3), Some(1))); + for t in (2000..4000).step_by(50) { + pump(&mut h, &mut g, t, true); + } + assert_eq!(g[1].actors[&2].application["kart"].value, b"spawned"); + g[0].publish_application("kart", vec![], 4000); + for t in (4000..6000).step_by(50) { + pump(&mut h, &mut g, t, true); + } + assert!(g[1].actors[&2].application["kart"].value.is_empty()); + for p in g[0].goodbye() { + h.receive(10, &p.data, 6000); + } + // Only the second guest remains; service it without reviving the first. + for t in (6000..7000).step_by(50) { + for p in g[1].service(t) { + h.receive(11, &p.data, t); + } + for p in h.service(t) { + if p.peer == 11 { + g[1].receive(1, &p.data, t); + } + } + } + assert!(!g[1].actors.contains_key(&2)); +} +fn wire(actor: u64, seq: u32, key: &str, value: &[u8]) -> Vec { + let mut b = packed::header(7, actor, APPLICATION, seq); + b.push(key.len() as u8); + b.extend(key.as_bytes()); + b.extend(value); + b +} +#[test] +fn spoofed_owners_old_updates_and_oversized_records_are_rejected() { + let mut h = Session::new(7, info(1), None); + let mut g = vec![ + Session::new(7, info(2), Some(1)), + Session::new(7, info(3), Some(1)), + ]; + for t in (0..1000).step_by(50) { + pump(&mut h, &mut g, t, false); + } + h.receive(10, &wire(3, 10, "state", b"forged"), 1000); + assert!(h.actors[&3].application.is_empty()); + h.receive(10, &wire(2, 5, "state", b"new"), 1000); + h.receive(10, &wire(2, 4, "state", b"old"), 1001); + assert_eq!(h.actors[&2].application["state"].value, b"new"); + h.receive(10, &wire(2, 6, "state", &vec![0; 1025]), 1002); + assert_eq!(h.actors[&2].application["state"].value, b"new"); + assert!(!h.publish_application("big", vec![0; 1025], 0)); + for i in 0..MAX_APP_KEYS { + assert!(h.publish_application(&format!("k{i}"), vec![1], 0)); + } + assert!(!h.publish_application("overflow", vec![], 0)); + assert!(h.publish_application("k0", vec![], 0)); +} +#[test] +fn many_mod_records_eventually_reach_every_player() { + let mut h = Session::new(7, info(1), None); + let mut g: Vec<_> = (2..=10) + .map(|id| Session::new(7, info(id), Some(1))) + .collect(); + for (i, g) in g.iter_mut().enumerate() { + for k in 0..24 { + assert!(g.publish_application(&format!("object{k}"), vec![i as u8; 900], 0)); + } + } + for t in (0..30000).step_by(50) { + pump(&mut h, &mut g, t, true); + } + for guest in &g { + assert_eq!(guest.actors.len(), 10); + for (&id, a) in &guest.actors { + if id != 1 { + assert_eq!(a.application.len(), 24, "peer {} actor {id}", guest.local); + } + } + } +} diff --git a/crates/skate-net/tests/v2.rs b/crates/skate-net/tests/v2.rs index 19d4a36c..ac189116 100644 --- a/crates/skate-net/tests/v2.rs +++ b/crates/skate-net/tests/v2.rs @@ -254,3 +254,13 @@ fn repeated_host_migration_preserves_local_state_and_rebuilds_ten_player_lobby() } } } + +#[test] +fn occupied_driver_flag_preserves_root_without_native_collision_parts() { + let mut frame=body();frame.enabled=1u64<<62;frame.root=pose([10.,4.,20.],0.4); + let packed=Packed::body(&frame).unwrap(); + let bytes=packed::delta(1,2,packed::BODY,1,&packed,None); + let decoded=packed::apply(&bytes,None).unwrap().unpack_body().unwrap(); + assert_eq!(decoded.enabled,1u64<<62);assert_eq!(decoded.root.p,frame.root.p); + frame.enabled=1u64<<61;assert!(Packed::body(&frame).is_none()); +} diff --git a/crates/skate-vehicles/src/lib.rs b/crates/skate-vehicles/src/lib.rs index 49954209..15ab1e13 100644 --- a/crates/skate-vehicles/src/lib.rs +++ b/crates/skate-vehicles/src/lib.rs @@ -34,6 +34,7 @@ pub struct Vehicle { pub controls: Controls, rider: ColliderHandle, occupied: bool, + pub remote: bool, inverted_time: f32, pub ejection: Option, } @@ -136,7 +137,7 @@ impl Simulation { body, controller, controls: Controls::default(), - rider, occupied: false, inverted_time: 0., ejection: None, + rider, remote: false, occupied: false, inverted_time: 0., ejection: None, }, ); Ok(id) @@ -157,6 +158,7 @@ impl Simulation { let before = self.capture_riders(); self.world.integration_parameters.dt = h; for v in self.vehicles.values_mut() { + if v.remote {continue;} let c = v.controls; let speed = v.controller.current_vehicle_speed; let d = &v.definition; diff --git a/crates/skate-vehicles/src/safety.rs b/crates/skate-vehicles/src/safety.rs index 10314807..c947c5c4 100644 --- a/crates/skate-vehicles/src/safety.rs +++ b/crates/skate-vehicles/src/safety.rs @@ -22,7 +22,7 @@ impl Simulation { self.vehicles.get_mut(&id)?.ejection.take() } pub(super) fn capture_riders(&self) -> BTreeMap { - self.vehicles.iter().filter(|(_, v)| v.occupied && v.definition.rider_safety.enabled) + self.vehicles.iter().filter(|(_, v)| !v.remote && v.occupied && v.definition.rider_safety.enabled) .map(|(&id, v)| { let body=&self.world.bodies[v.body]; let point=body.position().transform_point(Vector::from_array(v.definition.seat)); diff --git a/crates/skate-vehicles/tests/simulation.rs b/crates/skate-vehicles/tests/simulation.rs index a3d57fe9..f32dc297 100644 --- a/crates/skate-vehicles/tests/simulation.rs +++ b/crates/skate-vehicles/tests/simulation.rs @@ -246,3 +246,29 @@ fn occupied_ramp_driving_does_not_false_bail() { } assert!(climbed); } + +#[test] +fn local_car_collides_with_remote_chassis_and_can_eject() { + use skate_vehicles::rapier3d::prelude::*; + let mut s=simulation();let local=s.spawn(definition(),[0.,0.7,-4.],0.).unwrap(); + let remote=s.spawn(definition(),[0.,0.7,0.],0.).unwrap(); + for _ in 0..120 {s.step(1./120.);} + let remote_body=s.vehicles[&remote].body; + s.vehicles.get_mut(&remote).unwrap().remote=true; + s.world.bodies[remote_body].set_body_type(RigidBodyType::KinematicVelocityBased,true); + s.world.bodies[remote_body].set_linvel(Vector::ZERO,true); + s.world.bodies[remote_body].set_angvel(Vector::ZERO,true); + let before=s.pose(remote).unwrap().0; + s.set_occupied(local,true); + let body=s.vehicles[&local].body; + s.world.bodies[body].set_linvel(Vector::Z*15.,true); + let mut contact=false;let mut ejected=false; + for _ in 0..120 { + s.step(1./120.); + contact |= s.world.narrow_phase.contact_pairs().any(|p|p.total_impulse_magnitude()>0.); + ejected |= s.take_ejection(local).is_some(); + } + assert!(contact);assert!(ejected,"crash should produce a native bail request"); + assert!(s.pose(local).unwrap().0[2] Current integrated build: `PLAY-MARIO-KART.bat` / `Build-VehicleSDK.ps1`. +> Mod replication and car collisions use protocol 5. See [Multiplayer mod SDK](multiplayer-mods.md). +> Older standalone test-launcher instructions below describe the original networking work. + # 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. diff --git a/docs/multiplayer-mods.md b/docs/multiplayer-mods.md new file mode 100644 index 00000000..0df1107d --- /dev/null +++ b/docs/multiplayer-mods.md @@ -0,0 +1,139 @@ +# Multiplayer mod SDK (protocol 5) + +Use the same game build and map on every PC. Install and enable the same mod ZIP +on each PC for that mod's shared objects. Launch `PLAY-MARIO-KART.bat`, then use +**Escape > Multiplayer** to host or join. The build includes the optional Steam +relay; Steam must be running for Steam lobbies. Direct local testing remains +available. `Build-VehicleSDK.ps1` builds and stages both executables without launching them. + +## What synchronizes automatically + +| SDK feature | Multiplayer behavior | +| --- | --- | +| `scene.cube` / `scene.remove` | Owner-scoped shared visual objects; updates, removal and late joins supported. Cubes remain non-colliding. | +| `vehicle.spawn/remove/reset/tune/control` | Matching package supplies the model and definition. Owner sends chassis pose, velocities, wheel state, controls, tuning and occupancy. Other peers render and collide with replicas. | +| Vehicle entry/exit/steering | The final displayed native skeleton, including stance blending and arm steering, is replicated. Seated riders follow the same car render sample. | +| Vehicle engine audio | Each occupied car has a smoothed engine voice; other players' cars attenuate with distance. | +| Vehicle crash/inversion | The owning player decides the ejection, preserving pre-impact momentum. Their native wipeout/ragdoll then uses existing player replication. | +| Trainer / animation replacements / teleport | Run on the owning player's game. Their resulting movement, pose and ragdoll replicate through native player networking. Remote tuning never overwrites your local controls. | +| UI, mod settings windows, input, timers and logs | Local to each player. No duplicate remote input execution. | +| Custom Lua scores, checkpoints or game rules | Use `sdk.net.publish/read`; Lua variables alone are not shared. | + +This applies to the SDK, not a special Mario Kart network script. Existing trainer +beacons/trails and vehicle mods use these paths without additional networking code. +The imported multiplayer renderer still displays remote players using the stock +skater; custom character appearance transfer is a separate feature. + +## Matching and ownership + +A world/state record carries its mod ID and a portable fingerprint of package-relative +file names and file contents, including scripts and assets. Absolute installation +paths and ZIP timestamps do not participate. Different assets or scripts count as +a different package even if the version text is unchanged. The receiver must have +the matching package enabled and running. Mismatches appear in the multiplayer HUD. +No ZIP download, remote Lua execution, or arbitrary remote asset path is allowed. + +Object identity is `(player ID, mod ID, key)`. Both players may spawn `kart` without +replacing each other's car. The spawning player remains the simulation owner and +driver; transferring a vehicle to another player or sharing a passenger seat is not +implemented. Disconnecting, disabling a mod or removing an object removes its +replicas. Resetting a car updates its existing replica. Joining later receives the +current state rather than replaying callbacks. Stable keys should be reused. + +Settings are personal. Shared vehicle tuning follows its owner's settings. For a +single shared course or scoreboard, have one designated peer (usually the lobby +host) own it; do not run the same authoritative rule independently on every peer. +Check `local_id`, `active` and `is_host` each update because connections and hosts +can change. Objects owned by a departed peer disappear; a replacement host must +recreate host-owned game rules/objects if your mod needs that behavior. + +## Shared-state API + +```lua +local previous_id +return { + on_update = function(e) + local net = sdk.net.info() + -- Keep player IDs as strings; they are 64-bit and can exceed Lua's exact range. + if previous_id ~= net.local_id then + previous_id = net.local_id + sdk.net.publish("ready", {ready=true}) + end + local mine = sdk.net.read(net.local_id, "ready") + -- net.states[sdk.mod_id] maps peer ID strings to that peer's state keys. + for peer, values in pairs((net.states or {})[sdk.mod_id] or {}) do + local ready = sdk.net.read(peer, "ready") + -- Display results locally; only mutate your own authoritative state. + end + end +} +``` + +- `sdk.net.info()` returns `{active, local_id, is_host, states, status}`. Offline ID + is `"0"` and `is_host=true`. State is still readable locally while offline. +- `sdk.net.publish(key, value)` stores this mod's latest value under the local + player. Values can be JSON-compatible scalars/tables, at most 512 encoded bytes. + Keys follow normal SDK key rules (1..64 characters). `nil` clears a value. +- `sdk.net.read(peer_id, key)` reads only this mod's value for that peer. Missing + peers/keys return `nil`. Poll this from an update callback; no remote callback + runs and there is no exactly-once event API. +- Publish durable state, such as a counter or `{sequence=12, checkpoint=3}`, rather + than a one-frame pulse. Fast updates may coalesce into the newest value. +- A Lua callback's commands still commit only after it succeeds. Failing callbacks + do not send their queued state changes. + +## Collision and authority contract + +Native players already use owner simulation. Cars follow the same model: your +Rapier world owns your cars, and remote cars use kinematic collision proxies. +Chassis collisions affect your local simulation and can trigger a local rider bail; +the resulting motion is sent back. Two locally owned cars use ordinary dynamic +Rapier contacts. Remote cars include the occupied rider capsule. + +Native skating/board collision queries include nearby car chassis. Rapier also +receives nearby native player/board proxies (small spheres around active physical +parts). This bridges the two physics engines; it is an approximation of the native +articulated hitboxes. A seated driver's native standing hitboxes are disabled in +network snapshots so they cannot collide at their old skating position. + +Remote positions use bounded extrapolation (150 ms), limited correction velocity, +and fixed-step render interpolation. Rider attachment uses the car's displayed +transform. Large resets snap instead of drawing a car through the map. This is +not a host-authoritative shared Rapier world: delayed impacts can differ across +peers and are corrected from each owner's next state. It does not promise exactly +conserved cross-client momentum or competitive anti-cheat. + +## Bounds and failure behavior + +Application datagrams share the native lobby's byte budget and MTU (1200 bytes). +Native body/pose traffic has priority. Latest records are acknowledged, resent +with varied retry intervals after loss, and relayed only by authenticated owners. +Records contain at most 1024 value bytes and 128 key bytes; 256 wire keys per player +per session includes deletion tombstones. SDK data has a 128-live-record limit; +vehicle state is additional. Reuse keys rather than generating unlimited unique +IDs. A full network record includes metadata, so oversized world descriptors are +reported in the multiplayer HUD instead of being truncated. Typical kart and +trainer records fit. Heavy object loads lower effective update frequency. + +The existing limits remain 8 vehicles per mod and 32 local vehicles. Up to 288 +remote vehicle replicas are admitted across the other nine players. No peer may +write another peer's state; stale revisions and oversized packets are rejected. +Leaving a lobby clears remote replicas. Map changes leave the lobby and reset the +native collision schema. + +## Verification + +Run headless checks (these do not start the game): + +```powershell +cargo test --locked -p skate-net -p skate-mods -p skate-vehicles +cargo check --locked -p skate-game --bin skate3rust --no-default-features +``` + +Coverage includes loss/reordering, late joins, tombstones/disconnects, ten-player +record replication, spoofed owners and bounds, portable asset fingerprints, Lua +state transaction rollback, remote chassis collisions and crash ejection. Manual +follow-up: two PCs with matching enabled ZIPs; spawn separate cars, drive into one +another, hit a standing player, bail, reset, join late, disable/re-enable a mod, and +check trainer markers and mod mismatch messages. No game was launched as part of +these automated checks. diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md index 4dd5ef63..36dce394 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -1,6 +1,14 @@ -> 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. +> Current integrated build: `PLAY-MARIO-KART.bat` / `Build-VehicleSDK.ps1`. +> Mod replication and car collisions use protocol 5. See [Multiplayer mod SDK](multiplayer-mods.md). +> Older standalone test-launcher instructions below describe the original networking work. -## Steam lobby test (current build) +> Historical standalone 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. + + + + + +## Original standalone Steam lobby test 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. diff --git a/docs/steam-p2p-plan.md b/docs/steam-p2p-plan.md index d7ae4ce6..d131a132 100644 --- a/docs/steam-p2p-plan.md +++ b/docs/steam-p2p-plan.md @@ -1,5 +1,11 @@ +> Current integrated build: `PLAY-MARIO-KART.bat` / `Build-VehicleSDK.ps1`. +> Mod replication and car collisions use protocol 5. See [Multiplayer mod SDK](multiplayer-mods.md). +> Older standalone test-launcher instructions below describe the original networking work. + > 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. diff --git a/docs/vehicle-sdk.md b/docs/vehicle-sdk.md index e14297f3..f5836b15 100644 --- a/docs/vehicle-sdk.md +++ b/docs/vehicle-sdk.md @@ -154,13 +154,17 @@ the mod (or wait for automatic rescan) to install new clips. Never edit .cache. ## Current boundaries -The native skating simulation is suspended while driving, and normal skating -resumes on exit. Vehicle time pauses with Escape and during replay. Replay entry -and session-marker controls are blocked while driving. Vehicle motion is not -recorded in skating replays. Native skater-versus-vehicle impact forces are not -bridged yet: vehicles collide with the map and one another, but parked vehicles -are not native skating obstacles. This API does not include weapons, damage, -network synchronization or a racing ruleset. +The native skating simulation is suspended while driving and resumes on exit. +Offline vehicle time pauses with Escape; online physics continues while menus are +open and unattended controls fall back to braking. Replay entry and session-marker +controls are blocked while driving. Vehicle motion is not recorded in skating replays. + +Vehicles collide with the map, other cars and nearby native player/board proxies. +Native skating collision queries include car chassis. Matching enabled packages +replicate vehicles, tuning, wheel/rider poses, occupancy and engine audio. Crash bails +return to normal native ragdoll networking. See [Multiplayer mod SDK](multiplayer-mods.md) +for owner simulation, latency limits, hitbox approximations and late joins. Driving +another player's vehicle, passengers, weapons, damage and race rules are not built in. Verification uses headless Rapier tests and window-free Lua tests. The game is not launched automatically; rendering, entry/exit and handling need manual playtesting. @@ -205,7 +209,7 @@ Opt in per definition: ``` The host synthesizes an original layered exhaust pulse with filtered noise. No downloaded -sound asset or extra file is required. It plays for the occupied vehicle; parked engines +sound asset or extra file is required. It plays for occupied local and remote vehicles, with distance attenuation for remote engines; parked engines are silent. Pitch follows speed and absolute throttle, including reverse and free revving while stopped. Full throttle increases volume; releasing it smoothly drops the engine back towards idle. Pitch/volume changes are smoothed, Escape/replay fades it silent, and exiting diff --git a/mods/README.md b/mods/README.md index 12194a7e..d75d0476 100644 --- a/mods/README.md +++ b/mods/README.md @@ -18,3 +18,10 @@ Development folders are also supported. Do not install duplicate IDs (including folder and its ZIP). Ignore .cache: it is managed by the loader. Settings live outside packages. Standalone executables default to a mods folder beside the executable; SKATE3_MODS is the explicit override used by this project's launcher. + +## Multiplayer + +Use matching enabled ZIPs and the same integrated build on all peers. World objects +and vehicles synchronize through the SDK; Lua shared rules use `sdk.net`. +See [Multiplayer mod SDK](../docs/multiplayer-mods.md) for ownership, collisions, +late joins and shared-state examples. diff --git a/sdk/AGENTS.md b/sdk/AGENTS.md index 6bcb272a..9cd06e95 100644 --- a/sdk/AGENTS.md +++ b/sdk/AGENTS.md @@ -9,8 +9,9 @@ Do not invent API calls or assume this is an unrestricted Lua/native-code plugin 1. `docs/mod-packages.md`: ZIP structure, packaging and installation. 2. `sdk/skate.lua`: callable functions, argument types and bounds; annotations only. 3. `docs/lua-modding.md`: manifest, settings, lifecycle, quotas, callbacks and examples. -4. For vehicles, `docs/vehicle-sdk.md` and `sdk/examples/mario-kart/vehicle.json`. -5. For rider animations, `docs/mixamo-vehicle-workflow.md`. For character conversion, +4. `docs/multiplayer-mods.md`: automatic replication, matching packages, ownership and shared state. +5. For vehicles, `docs/vehicle-sdk.md` and `sdk/examples/mario-kart/vehicle.json`. +6. For rider animations, `docs/mixamo-vehicle-workflow.md`. For character conversion, `docs/mixamo-to-skate.md` and `docs/custom-models.md`. Paths are relative to the repository root. Authoritative implementations are @@ -28,6 +29,10 @@ or implement/test a shared host API; do not fake an unsupported call in the mod. - Return a callback table from main.lua. Use owner-local stable keys for visuals, read settings via sdk.settings, edge-detect held inputs, and clean/reset transient state on world_changed. Refresh vehicle controls from on_fixed_update. +- Decide which player owns each shared rule. Cubes and vehicles replicate automatically; + UI/input stay local. Use sdk.net.publish/read for Lua state and retain player IDs as strings. + Reuse stable keys; do not broadcast one-frame events or independently award shared scores + on every client. Consider host changes, late joins and mod disable cleanup. - Keep assets inside the package and use relative forward-slash paths. The entry and vehicle filenames need not be the example names, but their references must match. The game loads one Lua entry file; do not assume require/dofile or arbitrary file I/O. @@ -38,7 +43,7 @@ cargo run --locked -p skate-mods --example check_mod -- sdk/examples/your-mod python tools/package_mod.py sdk/examples/your-mod mods/your-mod.zip ``` -- For code/API changes, also run `cargo test --locked -p skate-mods -p skate-vehicles`. +- For code/API changes, also run `cargo test --locked -p skate-mods -p skate-vehicles -p skate-net`. Check the game crate and build with `Build-VehicleSDK.ps1` when the host changed. The helper packages the two bundled examples; package a new mod separately. - The checker checks structure, limits, schema and Lua syntax without running the mod. diff --git a/sdk/README.md b/sdk/README.md index ad4fec9b..0ec379ba 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -6,3 +6,10 @@ it to make a mod. It links the package format, complete API, examples and valida Editable sources: `examples/`. Player-installable output: project-root `mods/*.zip`. Use `tools/package_mod.py` to validate and package. The current integrated game launcher is `PLAY-MARIO-KART.bat`; older feature-specific launchers are historical builds. + +## Multiplayer + +Use matching enabled ZIPs and the same integrated build on all peers. World objects +and vehicles synchronize through the SDK; Lua shared rules use `sdk.net`. +See [Multiplayer mod SDK](../docs/multiplayer-mods.md) for ownership, collisions, +late joins and shared-state examples. diff --git a/sdk/examples/mario-kart/README.md b/sdk/examples/mario-kart/README.md index ac27f010..9dd949fc 100644 --- a/sdk/examples/mario-kart/README.md +++ b/sdk/examples/mario-kart/README.md @@ -34,3 +34,10 @@ Hard crashes, strong head/torso hits and sustained inversion now eject the rider native bail physics with carried momentum. An occupied-seat capsule protects the torso against map geometry. Tune rider_safety in vehicle.json; see docs/vehicle-sdk.md for threshold units, vehicle_bailed events and the native/Rapier collision boundary. + +## Multiplayer + +Use matching enabled ZIPs and the same integrated build on all peers. World objects +and vehicles synchronize through the SDK; Lua shared rules use `sdk.net`. +See [Multiplayer mod SDK](../../../docs/multiplayer-mods.md) for ownership, collisions, +late joins and shared-state examples. diff --git a/sdk/examples/native-trainer/README.md b/sdk/examples/native-trainer/README.md index 451f4fa4..55704f93 100644 --- a/sdk/examples/native-trainer/README.md +++ b/sdk/examples/native-trainer/README.md @@ -51,3 +51,10 @@ Hold fakie stance disables automatic fakie stance switching; normal steering and Authoring folder: sdk/examples/native-trainer. Package with tools/package_mod.py into mods/native-trainer.zip. See sdk/AGENTS.md and docs/mod-packages.md. + +## Multiplayer + +Use matching enabled ZIPs and the same integrated build on all peers. World objects +and vehicles synchronize through the SDK; Lua shared rules use `sdk.net`. +See [Multiplayer mod SDK](../../../docs/multiplayer-mods.md) for ownership, collisions, +late joins and shared-state examples. diff --git a/sdk/skate.lua b/sdk/skate.lua index 820d65ab..59819342 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -27,6 +27,7 @@ ---@field player PlayerSnapshot ---@field map MapSnapshot ---@field animation AnimationInfo +---@field network NetworkInfo ---@field tick integer ---@field keys table ---@field actions number[] 18 values; Lua index 1 corresponds to native action 64. @@ -167,3 +168,23 @@ function sdk.vehicle.input() end ---@field velocity number[] Carried world velocity plus launch lift, m/s ---@field angular_velocity number[] World angular velocity, rad/s -- Configure rider_safety in the vehicle definition; see docs/vehicle-sdk.md. + +---@class NetworkInfo +---@field active boolean +---@field local_id string Keep 64-bit player IDs as strings. Offline: "0". +---@field is_host boolean +---@field states table>> Mod ID -> peer ID -> state keys. +---@field status string Matching/limit diagnostics. +sdk.net = {} +---@return NetworkInfo +function sdk.net.info() end +---@param key string Stable owner-local key, 1..64 characters. +---@param value any JSON-compatible value, at most 512 encoded bytes; nil clears. +function sdk.net.publish(key,value) end +---@param peer string Player ID from sdk.net.info(), never converted to a number. +---@param key string +---@return any Latest value for this mod/peer/key, or nil. +function sdk.net.read(peer,key) end +-- State coalesces; this is not an exactly-once event channel. +-- World cubes and owned vehicles replicate automatically for matching enabled mods. +-- See docs/multiplayer-mods.md for ownership, collision and late-join behavior.