Replicate SDK mod state and add multiplayer vehicle collision proxies

This commit is contained in:
chazmm committed 2026-09-08 23:29:13 +01:00
1 parent ce563a994c
commit 24ac089874
38 files changed
+1394 -91

No files matched your search

+17 -3
View File
@@ -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.'
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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
+16
View File
@@ -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::<Transform>(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<PlayerRoot>>().iter(world).next().copied().unwrap_or_default();
let status=world.resource::<AnimationStatus>();
let mut locals=std::collections::BTreeMap::new();
for b in &status.bindings {if let Some(t)=world.get::<Transform>(b.entity) {locals.entry(b.bone).or_insert((b.parent_bone,t.to_matrix()));}}
fn global(i:usize,locals:&std::collections::BTreeMap<usize,(Option<usize>,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::<SkaterRuntime>());
let basis=render_basis().inverse();
skate_net::packed::PoseState{root:crate::physics::network::pose(root.to_matrix()),bones:anchors.into_iter().map(|i|skate_net::Bone{index:i as u16,pose:crate::physics::network::pose(if locals.contains_key(&i) {global(i,&locals,0)*basis} else {native_matrix(world.resource::<SkaterRuntime>().render_pose[i])})}).collect()}
}
+1 -1
View File
@@ -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 };
+7 -1
View File
@@ -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::<vehicles::Vehicles>().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::<Mods>().animation_info,
"map":{"name":map.name,"generation":map.generation},"tick":physics.ticks,"keys":keys,"actions":actions,
"paused":world.resource::<crate::graphics_menu::Menu>().open,"replay":world.resource::<crate::replay::Replay>().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::<crate::physics::GamePhysics>().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::<crate::physics::SkaterRuntime>()
@@ -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)?;
}
+335
View File
@@ -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<String, Value>,
},
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<String>,
remote: BTreeMap<(u64, String), (u32, String, String)>,
states: BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>,
identity: u64,
elapsed: f32,
pub status: String,
}
pub(super) fn install(app: &mut App) {
app.init_resource::<ModSync>()
.add_systems(Update, synchronize.after(vehicles::present));
}
pub(super) fn snapshot(world: &World) -> Value {
let (active, id, host) = world
.get_resource::<crate::multiplayer::Multiplayer>()
.map_or((false, 0, true), |n| n.mod_identity());
let sync = world.resource::<ModSync>();
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::<ModSync>();
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::<Time<Real>>().delta_secs();
world.resource_scope(|world, mut sync: Mut<ModSync>| {
sync.elapsed += dt;
if sync.elapsed < 0.05 {
return;
}
sync.elapsed = 0.;
let (_, identity, _) = world
.resource::<crate::multiplayer::Multiplayer>()
.mod_identity();
if identity != sync.identity {
sync.published.clear();
sync.identity = identity;
}
let packages: BTreeMap<_, _> = world
.resource::<Mods>()
.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::<crate::multiplayer::Multiplayer>()
.publish_mod(&key, bytes)
{
failures += 1;
}
published.insert(key);
}
for key in sync.published.difference(&published) {
world
.resource_mut::<crate::multiplayer::Multiplayer>()
.publish_mod(key, vec![]);
}
sync.published = published;
let records = world
.resource::<crate::multiplayer::Multiplayer>()
.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::<Record>(&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<Mods>| {
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<Mods>| {
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::<vehicles::Vehicles>()
.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::<Vec<_>>().join(", ")
)
} else {
format!(
"Matching mods synchronized | {} remote records",
sync.remote.len()
)
};
});
}
pub(super) fn retire(world: &mut World, owner: &str) {
world
.resource_mut::<ModSync>()
.local
.retain(|(id, _), _| id != owner);
}
pub(super) fn clear(world: &mut World) {
let mut s = world.resource_mut::<ModSync>();
s.local.clear();
s.remote.clear();
s.states.clear();
}
+39 -45
View File
@@ -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<Handle<Engine>>, pitch: f32, volume: f32 }
pub(super) fn install(app: &mut App) {
app.add_audio_source::<Engine>().init_resource::<State>()
.add_systems(Update, update.after(super::present));
}
fn update(
mut commands: Commands, vehicles: Res<super::Vehicles>,
time: Res<Time<Real>>, menu: Option<Res<crate::graphics_menu::Menu>>,
replay: Res<crate::replay::Replay>, mut state: ResMut<State>,
mut sources: ResMut<Assets<Engine>>, mut voices: Query<(Entity, &mut AudioSink), With<EngineVoice>>,
pending: Query<Entity, With<EngineVoice>>,
) {
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<Handle<Engine>>,mix:BTreeMap<u64,(f32,f32)>}
pub(super) fn install(app:&mut App) {app.add_audio_source::<Engine>().init_resource::<State>().add_systems(Update,update.after(super::present));}
fn update(mut commands:Commands,vehicles:Res<super::Vehicles>,time:Res<Time<Real>>,menu:Option<Res<crate::graphics_menu::Menu>>,
replay:Res<crate::replay::Replay>,skater:Res<crate::physics::SkaterRuntime>,mut state:ResMut<State>,mut sources:ResMut<Assets<Engine>>,
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);}
}
}
+19 -5
View File
@@ -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<Scene>,
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<u64, network::Target>,
skaters: BTreeMap<(u64,usize),skate_vehicles::rapier3d::prelude::RigidBodyHandle>,
owned: BTreeMap<(String, String), Instance>,
driver: Option<Driver>,
pub(super) events: Vec<Value>,
clock: f32,
hidden: Vec<(Entity, Visibility)>,
pub(crate) pose: Option<Vec<Mat4>>,
pub(crate) network_pose: Option<skate_net::packed::PoseState>,
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::<Time<Fixed>>().delta_secs();
world.resource_scope(|world, mut v: Mut<Vehicles>| {
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(&current, 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};
});
}
@@ -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<Res<crate::graphics_menu::Menu>>,
net: Option<Res<crate::multiplayer::Multiplayer>>,
) -> 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::<Vehicles>();
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::<Vehicles>()
.owned
.get(&owned)
.is_some_and(|i| i.definition_path != state.definition);
if replace {
remove(world, owner, key);
}
if !world.resource::<Vehicles>().owned.contains_key(&owned) {
super::command(world, root, owner, spawn)?;
}
let mut v = world.resource_mut::<Vehicles>();
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<Transform> {
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<CollisionShape> {
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::<crate::multiplayer::Multiplayer>()
.collision_players();
if !v.occupied() {
frames.push((
0,
crate::physics::network::capture_body(
world.resource::<crate::physics::GamePhysics>(),
world.resource::<crate::physics::SkaterRuntime>(),
),
));
}
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<interpolation::Motion> {
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)
}
+30 -7
View File
@@ -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()<Duration::from_millis(500)).map(|(&id,r)|(id,r.body.clone())).collect()
}
pub(crate) fn mod_records(&self) -> Vec<(u64, String, u32, Vec<u8>)> {
self.lobby.as_ref().map_or_else(Vec::new, |l| l.actors.iter().filter(|(id, _)| **id != l.local)
.flat_map(|(&id, actor)| actor.application.iter().map(move |(key,r)| (id,key.clone(),r.seq,r.value.clone()))).collect())
}
pub(crate) fn publish_mod(&mut self, key: &str, value: Vec<u8>) -> 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<Multiplayer>) {
net.last_metrics = Instant::now();
}
}
fn prepare(net: Res<Multiplayer>, mut physics: ResMut<GamePhysics>, skater: Res<SkaterRuntime>) {
fn prepare(net: Res<Multiplayer>, mut physics: ResMut<GamePhysics>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
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<Multiplayer>, mut physics: ResMut<GamePhysics>, 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<Multiplayer>,
physics: Res<GamePhysics>,
mut label: Single<&mut Text, With<NetworkHud>>,
mods: Res<crate::modding::network::ModSync>,
) {
***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<Multiplayer>, physics: Res<GamePhysics>, skater: Res<SkaterRuntime>) {
fn send(mut net: ResMut<Multiplayer>, physics: Res<GamePhysics>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
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<Multiplayer>, skater: Res<SkaterRuntime>) {
fn send_pose(mut net: ResMut<Multiplayer>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
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();
+10 -1
View File
@@ -7,6 +7,7 @@ struct RemoteSkins {
actors: BTreeMap<u64, RemoteSkin>,
rest: Vec<Mat4>,
parents: Vec<i32>,
board: Option<usize>,
}
#[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<Mat4> {
}
fn present(
mut net: ResMut<Multiplayer>,
vehicles: Res<crate::modding::vehicles::Vehicles>,
mut skins: ResMut<RemoteSkins>,
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. {
+23
View File
@@ -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}});
}
}
+10
View File
@@ -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
+2 -1
View File
@@ -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<u64, String> {
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);
}
}
+3 -1
View File
@@ -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(),
+34
View File
@@ -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());
}
+1 -1
View File
@@ -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 {
+76 -1
View File
@@ -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<u8>, 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<String, Application>,
}
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<u8>, 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
}
+4 -4
View File
@@ -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<Self> {
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<BodyState> {
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 {
+119
View File
@@ -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<u8> {
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);
}
}
}
}
+10
View File
@@ -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());
}
+3 -1
View File
@@ -34,6 +34,7 @@ pub struct Vehicle {
pub controls: Controls,
rider: ColliderHandle,
occupied: bool,
pub remote: bool,
inverted_time: f32,
pub ejection: Option<Ejection>,
}
@@ -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;
+1 -1
View File
@@ -22,7 +22,7 @@ impl Simulation {
self.vehicles.get_mut(&id)?.ejection.take()
}
pub(super) fn capture_riders(&self) -> BTreeMap<u64, (Vector, Vector)> {
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));
+26
View File
@@ -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]<before[2]+1.,"car passed through remote chassis");
assert_eq!(s.pose(remote).unwrap().0,before,"remote simulation must not change its owner's pose");
}
+14 -2
View File
@@ -192,7 +192,7 @@ Matrices are affine, column-major 4×4 global joint transforms. Every frame must
## Trust, assets, compatibility and limits
Only install mods from authors you trust. Isolation is an engine fault-containment boundary, **not** an OS sandbox or a promise to safely execute hostile code. There is no raw world/entity access, pointer API, FFI, network API, writable Lua filesystem API, `io`, `os`, `debug`, `package`, `require`, coroutine library, `load`, `dofile`, `loadfile`, `pcall`, `xpcall`, or Lua GC controls. Standard base operations plus table/string/math/UTF8 are available. Removing protected calls prevents catching and endlessly retrying quota errors. Scripts must be UTF-8 source returning a callback table. Errors are reported rather than caught by mods.
Only install mods from authors you trust. Isolation is an engine fault-containment boundary, **not** an OS sandbox or a promise to safely execute hostile code. There is no raw world/entity access, pointer API, FFI, raw socket/network access, writable Lua filesystem API, `io`, `os`, `debug`, `package`, `require`, coroutine library, `load`, `dofile`, `loadfile`, `pcall`, `xpcall`, or Lua GC controls. Standard base operations plus table/string/math/UTF8 are available. Removing protected calls prevents catching and endlessly retrying quota errors. Scripts must be UTF-8 source returning a callback table. Errors are reported rather than caught by mods.
Each Lua allocator is limited to 8 MiB and each entry evaluation/invocation to approximately 101000 VM instructions (hook every 1000 instructions). A long-running C library operation does not execute VM hooks; parsing, garbage collection, file I/O and host work are not hard wall-clock bounded. Aggregate mod load can still lower frame rate. There is no process isolation, hostile-filesystem race defense, hard timeout for native operations or guarantee against implementation bugs in dependencies. Package paths reject traversal and absolute paths, canonicalize within the root and reject escapes. Symlink entries are rejected during scanning; do not use junctions/links in mods. Do not mutate package trees maliciously while reading them.
@@ -205,7 +205,7 @@ parsers. Vehicles support embedded GLB models and synthesized engine audio. Arbi
scene/texture/font/audio-bank loading is not exposed. Text uses the existing engine font.
No camera-controller override, native audio command, customiser-profile mutation, physics force injection, collision-geometry insertion, climbing controller installation, map-selection command, score manipulation, networking, cross-mod messages or persistent Lua save store is exposed in API 1. Existing character customisation, native audio integration and private climbing loaders retain their own lifetimes. Broad engine-internal access is deliberately absent. Useful current extension types include HUDs, timers, route/training challenges, native-reset navigation tools, visual world annotations, grind/bail instrumentation and constrained original body animation replacement.
No camera-controller override, native audio command, customiser-profile mutation, physics force injection, collision-geometry insertion, climbing controller installation, map-selection command, score manipulation, arbitrary network messaging, cross-mod messages or persistent Lua save store is exposed in API 1. Existing character customisation, native audio integration and private climbing loaders retain their own lifetimes. Broad engine-internal access is deliberately absent. Useful current extension types include HUDs, timers, route/training challenges, native-reset navigation tools, visual world annotations, grind/bail instrumentation and constrained original body animation replacement.
The runtime pins `mlua = 0.12.1`, with `lua54`, `vendored`, `serde`, `send`; Cargo.lock pins transitive build inputs. `send` supports Bevy Resource storage, but host scheduling keeps execution on the main thread. Lua is compiled into the application using the vendored static build; no player-side DLL/Lua install is required. The dependency has no Bevy coupling and compiles against the engine's Bevy 0.18.1/local patches. Release uses Windows MSVC with `-C target-feature=+crt-static` and no default game features. Primary selection references: [mlua source/readme](https://github.com/mlua-rs/mlua), [Lua memory and hook API](https://docs.rs/mlua/0.12.1/mlua/struct.Lua.html). A lockfile entry for the optional LuaJIT source builder does not enable LuaJIT: the selected language feature is Lua 5.4.
@@ -243,3 +243,15 @@ are local-only. Both examples are packaged as ZIPs in top-level mods/ for this b
All owner-created visuals are removed on disable, reload or fault. Native skating and
vehicle physics remain host-controlled; Lua does not receive memory pointers or World.
## Multiplayer
The integrated launcher includes native multiplayer. Matching enabled mod packages
automatically replicate world cubes and vehicles. Trainer/animation/teleport results
follow native player replication; UI and input remain local. `sdk.net.info()`,
`sdk.net.publish(key,value)` and `sdk.net.read(peer,key)` expose bounded, owner-scoped
latest state for custom Lua rules. This is not an exactly-once event API.
Read [Multiplayer mod SDK](multiplayer-mods.md) before authoring shared scores,
races or world objects. It specifies exact matching, string player IDs, limits,
collision authority, cleanup, host changes and supported versus local-only features.
+7
View File
@@ -132,3 +132,10 @@ outdated copy indefinitely. Cache contents do not become independent installed m
- [Native Trainer](../sdk/examples/native-trainer/README.md): tuning, HUD, markers, timers.
- [Vehicle SDK](vehicle-sdk.md): physics, controller input, ramp tuning and engine audio.
- [Mixamo workflow](mixamo-vehicle-workflow.md): calibrated native-rig animation authoring.
## 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](multiplayer-mods.md) for ownership, collisions,
late joins and shared-state examples.
+8
View File
@@ -1,5 +1,13 @@
> 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.
+139
View File
@@ -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.
+10 -2
View File
@@ -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.
+6
View File
@@ -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.
+12 -8
View File
@@ -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
+7
View File
@@ -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.
+8 -3
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+21
View File
@@ -27,6 +27,7 @@
---@field player PlayerSnapshot
---@field map MapSnapshot
---@field animation AnimationInfo
---@field network NetworkInfo
---@field tick integer
---@field keys table<string,boolean>
---@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<string,table<string,table<string,any>>> 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.