mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Replicate SDK mod state and add multiplayer vehicle collision proxies
This commit is contained in:
1 parent
ce563a994c
commit
24ac089874
38 files changed
+1394
-91
No files matched your search
+17
-3
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤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};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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. {
|
||||
|
||||
@@ -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}});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user