Merge Lua modding SDK, packaged mods and vehicle support

This commit is contained in:
chazmm committed 2026-09-08 23:46:35 +01:00
commit 57742e0e54
97 files changed
+9637 -42

No files matched your search

@@ -1,6 +1,7 @@
//! Complete TU3 Toolkit_AntiFlipTorque 82D94190, distinct from heading/manual.
use super::vector::{cross, dot3, normalize};
use crate::{point_graph::PointGraph, trigonometry::acos};
#[derive(Clone)]
pub struct AntiFlipSettings {
pub axis_96_response: PointGraph<8>, // +1832/+1864
pub axis_64_response: PointGraph<8>, // +1912/+1944
@@ -2,6 +2,7 @@
//! The research index has not recovered its original name. Keep its four
//! caller-supplied scalar arguments explicit instead of inventing units.
use super::vector::{clamp, dot3, normalize};
#[derive(Clone)]
pub struct GroundForceSettings {
pub range_1220: f32,
pub speed_scale_1224: f32,
+1
View File
@@ -2,6 +2,7 @@
//! AntiFlipTorque; the two are distinct. The caller owns board+284 history.
use crate::{physics::reciprocal_sqrt::estimate, point_graph::PointGraph, trigonometry::acos};
#[derive(Clone)]
pub struct HeadingSettings {
pub manual_wrong_wheel_scalar: f32, // +904
pub manual_damping: f32, // +908
@@ -2,6 +2,7 @@
use super::vector::{clamp, dot3};
use crate::{point_graph::PointGraph, trigonometry::acos};
#[derive(Clone)]
pub struct SlideFrictionSettings {
pub angle_response: PointGraph<16>, // +196/+260
pub speed_response: PointGraph<8>, // +324/+356
@@ -4,6 +4,7 @@
use super::vector::{clamp, dot3, normalize};
use crate::point_graph::PointGraph;
#[derive(Clone)]
pub struct SpeedModelSettings {
/// physics_speedmodel layout +0,+8,+16,+20,+24.
pub negative_gain: f32,
@@ -1,6 +1,7 @@
//! Complete TU3 Toolkit_CalcStraightenOutTorque 82D92B60.
use super::vector::{clamp, cross, dot3};
use crate::point_graph::PointGraph;
#[derive(Clone)]
pub struct StraightenSettings {
pub time_response: PointGraph<16>, // settings +768/+832
pub opposite_turn_limit: f32, // +896
@@ -0,0 +1,10 @@
//! Export the exact native order for an authored vehicle animation package.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 { return Err("Usage: vehicle_bone_names OnBoard.abin bone-names.json".into()); }
let bank = skate_data::abin::Bank::load(std::path::Path::new(&args[0]))?;
let hierarchy = bank.hierarchy().ok_or("Bank has no skeleton hierarchy")?;
std::fs::write(&args[1], serde_json::to_vec_pretty(&hierarchy.bone_names)?)?;
println!("Exported {} bone names", hierarchy.bone_names.len());
Ok(())
}
+3
View File
@@ -29,4 +29,7 @@ skate-data.workspace = true
half = "=2.7.1"
serde_json.workspace = true
serde.workspace = true
skate-mods = { path = "../skate-mods" }
skate-vehicles = { path = "../skate-vehicles" }
skate-net = { path = "../skate-net" }
+45
View File
@@ -231,3 +231,48 @@ pub(crate) fn native_matrix(matrix: NativeMatrix) -> Mat4 {
Vec3::from_array(matrix[3][..3].try_into().unwrap()).extend(1.0),
)
}
/// Vehicle clips use the same native model-space skeleton matrices as the SDK.
pub(crate) fn vehicle_pose(world:&mut World, pose:&[Mat4], steering:Option<(&[Mat4],f32)>) {
let board=world.resource::<crate::physics::SkaterRuntime>().animation.evaluator.frames.bone_names.iter().position(|n|n=="SKATEBOARD_ROOT");
world.resource_scope(|world,animation:Mut<AnimationStatus>| {
let basis=render_basis();
for binding in &animation.bindings {
let Some(global)=pose.get(binding.bone).map(|m|*m*basis) else {continue;};
let local=if let Some(parent)=binding.parent_bone {let Some(parent)=pose.get(parent) else {continue;};(*parent*basis).inverse()*global} else {global};
let mut target=Transform::from_matrix(local);
if let Some((turn,weight))=steering {
if let Some(global)=turn.get(binding.bone).map(|m|*m*basis) {
let local=if let Some(parent)=binding.parent_bone {turn.get(parent).map(|p|(*p*basis).inverse()*global).unwrap_or(global)} else {global};
target=crate::presentation::blend(target,Transform::from_matrix(local),weight.clamp(0.,1.));
}
}
if let Some(mut transform)=world.get_mut::<Transform>(binding.entity) {*transform=target;if Some(binding.bone)==board {transform.scale=Vec3::splat(0.001);}}
}
});
}
pub(crate) fn capture_vehicle_visual(world: &mut World) -> Vec<(Entity, Transform)> {
let mut ids:Vec<_>=world.resource::<AnimationStatus>().bindings.iter().map(|b|b.entity).collect();
ids.extend(world.query_filtered::<Entity,With<crate::world::PlayerRoot>>().iter(world));
ids.into_iter().filter_map(|id|world.get::<Transform>(id).copied().map(|t|(id,t))).collect()
}
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()}
}
+23 -1
View File
@@ -17,6 +17,7 @@ mod authored_clips;
pub(crate) struct PoseEvaluator {
pub frames: AnimationFrames,
authored: authored_clips::Replacements,
mod_clips: std::sync::RwLock<std::collections::BTreeMap<String, authored_clips::Replacements>>,
}
impl PoseEvaluator {
@@ -29,6 +30,7 @@ impl PoseEvaluator {
Ok(Self {
frames: AnimationFrames::from_banks(banks)?,
authored: Default::default(),
mod_clips: Default::default(),
})
}
@@ -37,9 +39,29 @@ impl PoseEvaluator {
Ok(())
}
/// Reuses the existing constrained body-clip parser; never replaces board/trajectory data.
pub(crate) fn install_mod_clips(&self, owner: &str, text: &str) -> Result<(), String> {
let clips = authored_clips::Replacements::parse(text, &self.frames)?;
let mut all = self.mod_clips.write().map_err(|_| "Animation override lock poisoned")?;
for (id, other) in all.iter() {
if id != owner && clips.0.keys().any(|k| other.0.contains_key(k)) {
return Err(format!("Animation slots conflict with mod {id}"));
}
}
all.insert(owner.to_owned(), clips);
Ok(())
}
pub(crate) fn remove_mod_clips(&self, owner: &str) {
if let Ok(mut all) = self.mod_clips.write() { all.remove(owner); }
}
pub(crate) fn clear_mod_clips(&self) {
if let Ok(mut all) = self.mod_clips.write() { all.clear(); }
}
/// Executes the ordered stock tree. This uses the native immediate ACS
/// arithmetic; the host does not recreate packed animation job commands.
pub fn evaluate(&self, commands: &[PoseCommand]) -> Result<Vec<Sqt>, String> {
let mod_clips = self.mod_clips.read().map_err(|_| "Animation override lock poisoned")?;
let mut stack: Vec<Vec<Sqt>> = Vec::new();
for command in commands {
match command {
@@ -91,7 +113,7 @@ impl PoseEvaluator {
loops,
} => {
let stock = self.frames.clip(name)?;
let clip = self.authored.clip(&stock.name).unwrap_or(stock);
let clip = mod_clips.values().find_map(|c|c.clip(&stock.name)).or_else(||self.authored.clip(&stock.name)).unwrap_or(stock);
let mut pose = sample_clip(clip, *time)?;
if self.frames.has_trajectory {
let previous = sample_bone(clip, *previous_time, 0)?;
@@ -4,7 +4,7 @@ use bevy::math::{Mat4, Quat, Vec3};
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Default)]
pub(super) struct Replacements(BTreeMap<String, ClipFrames>);
pub(super) struct Replacements(pub(super) BTreeMap<String, ClipFrames>);
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
@@ -28,7 +28,7 @@ impl Replacements {
};
Self::parse(&text, frames).map_err(|e| format!("{}: {e}", path.display()))
}
fn parse(text: &str, frames: &AnimationFrames) -> Result<Self, String> {
pub(super) fn parse(text: &str, frames: &AnimationFrames) -> Result<Self, String> {
let file: File = serde_json::from_str(text).map_err(|e| e.to_string())?;
if file.version != 1 || file.bone_names != frames.bone_names || file.clips.len() > 32 {
return Err("Authored clip format or skeleton mismatch".into());
+3
View File
@@ -40,6 +40,8 @@ pub(crate) fn build(
let retail_scene = config.map.as_ref().is_some_and(|map| map.materials.iter().any(|m| m.retail_definition.is_some()));
let mut app = App::new();
crate::custom_models::register_source(&mut app);
app.register_asset_source("mods", bevy::asset::io::AssetSourceBuilder::platform_default(
&crate::modding::package_root().to_string_lossy(), None));
app.add_plugins(
DefaultPlugins
.set(AssetPlugin {
@@ -111,6 +113,7 @@ pub(crate) fn build(
));
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
app.add_plugins(crate::custom_models::CustomModelsPlugin);
app.add_plugins(crate::modding::ModdingPlugin);
crate::teleport_menu::install(&mut app);
app.add_plugins(crate::updater::UpdaterPlugin);
app.add_plugins(crate::multiplayer::MultiplayerPlugin);
+21 -2
View File
@@ -24,7 +24,7 @@ use bevy::prelude::*;
use crate::{app::FrameSet, config::Config};
#[derive(Component)]
struct GameplayCamera;
pub(crate) struct GameplayCamera;
pub(crate) struct CameraPlugin;
impl Plugin for CameraPlugin {
@@ -49,9 +49,13 @@ fn spawn(mut commands: Commands, config: Res<Config>, retail: Res<crate::retail_
}
}
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
#[derive(Default)]
pub(crate) struct VehicleCameraBlend { active:bool, previous:Option<Transform>, from:Option<Transform>, elapsed:f32 }
pub(crate) fn present(vehicles: Res<crate::modding::vehicles::Vehicles>, mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
replay: Res<crate::replay::Replay>,
virtual_time: Res<Time<Virtual>>, mut vehicle_blend: Local<VehicleCameraBlend>,
customiser: Option<Res<crate::customiser::Customiser>>,
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
if let Ok(window) = windows.single() {
@@ -75,6 +79,21 @@ fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
*transform = Transform::from_translation(eye).looking_at(center + right * (distance * 0.265625), Vec3::Y);
if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); }
}
let vehicle=vehicles.camera();
let active=vehicle.is_some();
if active!=vehicle_blend.active {
vehicle_blend.from=vehicle_blend.previous;vehicle_blend.elapsed=0.;vehicle_blend.active=active;
}
if let Some(pose)=vehicle {*transform=pose;}
vehicle_blend.elapsed+=virtual_time.delta_secs();
if let Some(from)=vehicle_blend.from {
if vehicle_blend.elapsed<0.5 && from.translation.distance(transform.translation)<50. {
let t=(vehicle_blend.elapsed/0.5).clamp(0.,1.);*transform=crate::presentation::blend(from,*transform,t*t*(3.-2.*t));
} else {vehicle_blend.from=None;}
}
// Vehicle motion is already interpolated with the rider/chassis. A separate
// follow filter introduces relative motion and makes fixed ticks visible.
vehicle_blend.previous=Some(*transform);
camera.is_active = true;
}
}
@@ -1,5 +1,7 @@
#[path = "motion_toggle_execute.rs"]
mod toggle_execution;
#[path = "motion_fakie_hold.rs"]
mod fakie_hold;
#[path = "motion_air_execute.rs"]
mod air_execution;
#[path = "motion_execute.rs"]
@@ -103,6 +105,8 @@ pub struct MotionHost {
pub applying_body_tilt: bool,
pub crouching_physical: Option<skate_core::animation::crouching::Physical>,
pub body_tilt_physical: Option<skate_core::animation::body_tilt::Physical>,
pub hold_fakie: bool,
automatic_fakie_conditions: Vec<bool>,
pub fakie_physical: Option<skate_core::animation::riding_fakie::Physical>,
/// PhysOutAnimation158/157, used directly by IsRidingGoofy82BA5AA8.
pub physical_stance: Option<(bool, bool)>,
@@ -252,6 +256,8 @@ impl MotionHost {
applying_body_tilt: false,
crouching_physical: None,
body_tilt_physical: None,
hold_fakie: false,
automatic_fakie_conditions: fakie_hold::bind(&graph.binding),
fakie_physical: None,
physical_stance: None,
flags: Default::default(),
@@ -306,6 +312,12 @@ impl MotionHost {
}
impl ConditionHost for MotionHost {
fn condition_activation(&mut self, condition: usize, frame: &Frame) -> u32 {
// Gate only the authored automatic switch transitions. Other fakie
// conditions (tricks, pushes, dismounts) still see the real stance.
if self.remap.conditions.get(condition).is_some_and(|&id|
fakie_hold::blocked(self.hold_fakie, &self.automatic_fakie_conditions, id)) {
return 0;
}
let result = self
.remap
.conditions
@@ -0,0 +1,82 @@
//! Trainer gates for the stock automatic fakie-to-switch animation transitions.
//! FakieTurn is player steering and must never be suppressed by this option.
use skate_data::state_graph::binding::{Binding, Node};
pub(super) fn bind(binding: &Binding) -> Vec<bool> {
let mut gates = vec![false; binding.operations.len()];
for transition in &binding.transitions {
let Some(target) = transition.target else { continue; };
let mut names = Vec::new();
let mut state = Some(target);
while let Some(id) = state {
names.push(binding.states[id].name.as_str());
state = binding.states[id].parent;
}
names.reverse();
if names != ["Motion", "OnBoard", "OnGround", "RidingIdle", "Riding", "Turning", "Switch"] {
continue;
}
if let Some(expression) = transition.expression {
mark(binding, expression, &mut gates);
}
}
gates
}
fn mark(binding: &Binding, expression: usize, gates: &mut [bool]) {
for child in &binding.expressions[expression].children {
match *child {
Node::Operation(id) if binding.operations[id].name == "IsRidingFakie" => gates[id] = true,
Node::Expression(id) => mark(binding, id, gates),
_ => {}
}
}
}
pub(super) fn blocked(enabled: bool, gates: &[bool], operation: usize) -> bool {
enabled && gates.get(operation).copied().unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use skate_data::state_graph::{GraphAttribute, GraphElement, StateGraph};
struct Tree(&'static str, Vec<(&'static str, &'static str)>, Vec<Tree>);
fn state(name: &'static str, children: Vec<Tree>) -> Tree { Tree("state", vec![("name", name)], children) }
fn condition() -> Tree { Tree("condition", vec![("name", "IsRidingFakie")], vec![]) }
fn transition(target: &'static str) -> Tree {
Tree("transition", vec![("target", target)], vec![Tree("expression", vec![("op", "and")], vec![condition()])])
}
fn add(tree: Tree, graph: &mut StateGraph) -> usize {
let id = graph.elements.len();
graph.elements.push(GraphElement { source_offset: id, tag: tree.0.into(),
attributes: tree.1.into_iter().map(|(name, text)| GraphAttribute {
name: name.into(), text: text.into(), float_bits: 0, boolean_byte: 0,
}).collect(), children: vec![] });
let children = tree.2.into_iter().map(|child| add(child, graph)).collect();
graph.elements[id].children = children;
id
}
#[test]
fn gates_idle_and_bump_switch_but_preserves_other_fakie_conditions() {
let tree = state("Motion", vec![state("OnBoard", vec![state("OnGround", vec![
state("RidingIdle", vec![
state("Bumped", vec![transition("Riding.Turning.Switch")]),
state("Riding", vec![state("Turning", vec![
state("Idle", vec![transition("Switch")]), state("Switch", vec![]),
state("Push", vec![Tree("expression", vec![], vec![condition()])]),
])]),
]),
// Same leaf name elsewhere must not be treated as automatic switching.
state("Other", vec![state("Idle", vec![transition("Switch")]), state("Switch", vec![])]),
])])]);
let mut graph = StateGraph { elements: vec![] }; add(tree, &mut graph);
let binding = Binding::from_graph(&graph).unwrap();
let gates = bind(&binding);
assert_eq!(gates.iter().filter(|&&gate| gate).count(), 2);
assert_eq!(gates.iter().filter(|&&gate| !gate).count(), 2);
for (id, gate) in gates.iter().enumerate() {
assert!(!blocked(false, &gates, id), "disabled trainer must preserve stock transitions");
assert_eq!(blocked(true, &gates, id), *gate);
}
assert!(!blocked(true, &gates, usize::MAX));
}
}
+11 -4
View File
@@ -218,7 +218,7 @@ fn setup(
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
for i in 0..15 {
for i in 0..16 {
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
@@ -263,6 +263,7 @@ pub(crate) fn interact(
mut transition: ResMut<crate::map_transition::MapTransition>,
mut customiser: ResMut<crate::customiser::Customiser>,
mut custom_models: ResMut<crate::custom_models::CustomModels>,
(mut mods, panel): (ResMut<crate::modding::ModMenu>, Res<crate::modding::EnabledPanel>),
nav: Res<crate::customiser::Navigation>,
mut physics: ResMut<crate::physics::GamePhysics>,
keys: Res<ButtonInput<KeyCode>>,
@@ -280,7 +281,7 @@ pub(crate) fn interact(
time.pause();
return;
}
if travel.open || travel.closed_this_frame || customiser.open || custom_models.open { return; }
if mods.open || travel.open || travel.closed_this_frame || customiser.open || custom_models.open { return; }
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
menu.open = !menu.open;
}
@@ -306,7 +307,8 @@ pub(crate) fn interact(
}
}
if menu.open {
let rows = if menu.multiplayer { 11 } else { 15 };
let rows = if menu.multiplayer { 11 } else { 16 };
if !panel.focused {
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + rows - 1) % rows;
}
@@ -319,7 +321,9 @@ pub(crate) fn interact(
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 {
action = Some((menu.selected, 1));
}
}
for (interaction, row) in &buttons {
if panel.dragging() { continue; }
if *interaction == Interaction::Pressed {
menu.selected = row.0;
action = Some((row.0, 1));
@@ -427,6 +431,7 @@ pub(crate) fn interact(
12 => custom_models.begin(),
13 => menu.status = updater.open(false),
14 => travel.open = true,
15 => mods.begin(),
_ => {}
}
}
@@ -512,6 +517,7 @@ fn labels(
customiser: Res<crate::customiser::Customiser>,
custom_models: Res<crate::custom_models::CustomModels>,
travel: Res<crate::teleport_menu::Travel>,
mods: Res<crate::modding::ModMenu>,
net: Res<crate::multiplayer::Multiplayer>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
@@ -519,7 +525,7 @@ fn labels(
mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without<MenuRoot>>,
) {
root.display = if menu.open && !travel.open && !customiser.open && !custom_models.open {
root.display = if menu.open && !mods.open && !travel.open && !customiser.open && !custom_models.open {
Display::Flex
} else {
Display::None
@@ -613,6 +619,7 @@ fn labels(
10 => "Character customiser".into(),
12 => "Custom models".into(),
13 => "Updates".into(),
15 => "Mods".into(),
14 => "Teleport…".into(),
_ => "Multiplayer".into(),
}
+1
View File
@@ -31,6 +31,7 @@ mod skater_animation;
mod verification;
mod performance;
mod graphics_menu;
mod modding;
mod customiser;
mod customiser_parts;
mod customiser_material;
+356
View File
@@ -0,0 +1,356 @@
use super::Mods;
use bevy::prelude::*;
#[derive(Resource, Default)]
pub(crate) struct ModMenu {
pub open: bool,
just_opened: bool,
id: Option<String>,
selected: usize,
editing: Option<(String, String)>,
status: String,
}
impl ModMenu {
pub fn configure(&mut self, id: String) {
self.begin();
self.id = Some(id);
}
pub fn begin(&mut self) {
self.open = true;
self.just_opened = true;
self.id = None;
self.selected = 0;
}
}
#[derive(Component)]
struct Root;
#[derive(Component)]
struct Row(usize);
#[derive(Component)]
struct Label(usize);
#[derive(Component)]
struct Badge(usize);
#[derive(Component)]
struct Detail;
#[derive(Clone)]
enum Action {
Select(String),
Enable(String),
Reload(String),
Reset(String),
Setting(String, String),
Scan,
OpenFolder,
Back,
}
fn rows(menu: &ModMenu, mods: &Mods) -> Vec<(String, Action)> {
let manager = &mods.manager;
if let Some(id) = &menu.id {
if let Some(p) = manager.packages.get(id) {
let mut rows = vec![
(
if p.running() {
"DISABLE MOD".into()
} else {
"ENABLE MOD".into()
},
Action::Enable(id.clone()),
),
("Reload from disk".into(), Action::Reload(id.clone())),
(
"Reset settings to defaults".into(),
Action::Reset(id.clone()),
),
];
for (key, s) in &p.manifest.settings {
rows.push((
format!("{}: {}", s.label, p.settings[key]),
Action::Setting(id.clone(), key.clone()),
));
}
rows.push(("Back".into(), Action::Back));
return rows;
}
}
let mut rows: Vec<_> = manager
.packages
.iter()
.map(|(id, p)| (p.manifest.name.clone(), Action::Select(id.clone())))
.collect();
rows.push(("Open mods folder".into(), Action::OpenFolder));
rows.push(("Rescan packages".into(), Action::Scan));
rows.push(("Back to pause menu".into(), Action::Back));
rows
}
pub(super) fn install(app: &mut App) {
app.add_systems(PostStartup, setup)
.add_systems(
PreUpdate,
input
.after(crate::graphics_menu::MenuInput)
.before(crate::map_transition::MapTransitionSet),
)
.add_systems(Update, draw);
}
fn setup(mut commands: Commands) {
commands.spawn((Root,GlobalZIndex(20),Node{display:Display::None,width:percent(100),height:percent(100),position_type:PositionType::Absolute,align_items:AlignItems::Center,justify_content:JustifyContent::Center,..default()},BackgroundColor(Color::srgba(0.015,0.025,0.04,0.98)))).with_children(|root| {
root.spawn((Node{width:px(760),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(5),..default()},BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
panel.spawn((Text::new("MODS — Lua SDK 1"),TextFont{font_size:28.,..default()},TextColor(Color::WHITE)));
for i in 0..8 { panel.spawn((Button,Row(i),Node{min_height:px(32),padding:UiRect::all(px(6)),justify_content:JustifyContent::SpaceBetween,..default()},BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {row.spawn((Label(i),Text::new(""),TextFont{font_size:17.,..default()},TextColor(Color::WHITE)));row.spawn((Badge(i),Text::new(""),TextFont{font_size:16.,..default()},TextColor(Color::WHITE)));}); }
panel.spawn((Detail,Text::new(""),TextFont{font_size:15.,..default()},TextColor(Color::srgb(0.65,0.85,0.85))));
panel.spawn((Text::new("Arrows / D-pad: select & adjust | Enter / A: choose | Esc / B: back\nStrings: Enter then type; Enter saves, Esc cancels. More rows scroll automatically."),TextFont{font_size:14.,..default()},TextColor(Color::WHITE)));
});
});
}
fn input(
mut menu: ResMut<ModMenu>,
mut mods: ResMut<Mods>,
keys: Res<ButtonInput<KeyCode>>,
nav: Res<crate::customiser::Navigation>,
buttons: Query<(&Interaction, &Row), Changed<Interaction>>,
mut typing: MessageReader<bevy::input::keyboard::KeyboardInput>,
) {
if !menu.open {
typing.clear();
return;
}
if menu.just_opened {
menu.just_opened = false;
typing.clear();
return;
}
if let Some((key, mut value)) = menu.editing.take() {
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x2000 != 0 {
typing.clear();
return;
}
if keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0 {
if let Some(id) = &menu.id {
menu.status = mods
.manager
.setting(id, &key, value.into())
.err()
.unwrap_or_else(|| "Saved".into());
}
typing.clear();
return;
}
if keys.just_pressed(KeyCode::Backspace) {
value.pop();
}
for e in typing.read() {
if e.state == bevy::input::ButtonState::Pressed {
if let Some(t) = &e.text {
for c in t.chars().filter(|c| !c.is_control()) {
if value.chars().count() < 128 {
value.push(c);
}
}
}
}
}
menu.editing = Some((key, value));
return;
}
typing.clear();
if keys.just_pressed(KeyCode::Escape) || nav.pressed & (0x2000 | 0x10) != 0 {
if menu.id.take().is_none() {
menu.open = false;
}
menu.selected = 0;
return;
}
let entries = rows(&menu, &mods);
let count = entries.len();
menu.selected = menu.selected.min(count - 1);
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + count - 1) % count;
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
menu.selected = (menu.selected + 1) % count;
}
let mut direction = 0;
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
direction = -1;
}
if keys.just_pressed(KeyCode::ArrowRight)
|| keys.just_pressed(KeyCode::Enter)
|| nav.pressed & (8 | 0x1000) != 0
{
direction = 1;
}
let offset = menu.selected / 8 * 8;
for (interaction, row) in &buttons {
if *interaction == Interaction::Pressed && row.0 + offset < count {
menu.selected = row.0 + offset;
direction = 1;
}
}
if direction == 0 {
return;
}
let action = entries[menu.selected].1.clone();
let result = match action {
Action::Select(id) => {
menu.id = Some(id);
menu.selected = 0;
Ok(())
}
Action::Back => {
if menu.id.take().is_none() {
menu.open = false;
}
menu.selected = 0;
Ok(())
}
Action::OpenFolder => {
#[cfg(target_os = "windows")]
let program = "explorer.exe";
#[cfg(target_os = "macos")]
let program = "open";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let program = "xdg-open";
std::process::Command::new(program)
.arg(mods.manager.root())
.spawn()
.map(|_| ())
.map_err(|e| format!("Could not open mods folder: {e}"))
}
Action::Scan => {
mods.manager.scan(true);
Ok(())
}
Action::Enable(id) => {
let enabled = !mods.manager.packages[&id].running();
mods.manager.enable(&id, enabled)
}
Action::Reload(id) => {
mods.manager.scan(true);
mods.manager.reload(&id);
Ok(())
}
Action::Reset(id) => mods.manager.reset(&id),
Action::Setting(id, key) => {
let p = &mods.manager.packages[&id];
let s = &p.manifest.settings[&key];
let value = &p.settings[&key];
let next = match s.kind.as_str() {
"boolean" => Some(serde_json::json!(!value.as_bool().unwrap())),
"number" => Some(serde_json::json!(
(value.as_f64().unwrap() + direction as f64 * s.step.unwrap())
.clamp(s.min.unwrap(), s.max.unwrap())
)),
"choice" => {
let i = s
.choices
.iter()
.position(|v| Some(v.as_str()) == value.as_str())
.unwrap_or(0);
Some(serde_json::json!(
s.choices
[(i as i32 + direction).rem_euclid(s.choices.len() as i32) as usize]
))
}
"string" => {
menu.editing = Some((key.clone(), value.as_str().unwrap().into()));
None
}
_ => None,
};
if let Some(value) = next {
mods.manager.setting(&id, &key, value)
} else {
Ok(())
}
}
};
menu.status = result.err().unwrap_or_default();
}
fn draw(
menu: Res<ModMenu>,
mods: Res<Mods>,
mut root: Single<&mut Node, With<Root>>,
mut labels: Query<(&Label, &mut Text), Without<Badge>>,
mut badges: Query<(&Badge, &mut Text, &mut TextColor), Without<Label>>,
mut buttons: Query<(&Row, &mut Node, &mut BackgroundColor), Without<Root>>,
mut detail: Single<&mut Text, (With<Detail>, Without<Label>, Without<Badge>)>,
) {
root.display = if menu.open {
Display::Flex
} else {
Display::None
};
if !menu.open {
return;
}
let entries = rows(&menu, &mods);
let selected = menu.selected.min(entries.len() - 1);
let offset = selected / 8 * 8;
for (label, mut text) in &mut labels {
**text = entries
.get(offset + label.0)
.map(|e| e.0.clone())
.unwrap_or_default();
}
for (row, mut node, mut color) in &mut buttons {
node.display = if offset + row.0 < entries.len() {
Display::Flex
} else {
Display::None
};
color.0 = if offset + row.0 == selected {
Color::srgb(0.10, 0.30, 0.34)
} else {
Color::srgb(0.08, 0.11, 0.15)
};
}
for (badge, mut text, mut color) in &mut badges {
let package = entries.get(offset + badge.0).and_then(|(_, a)| match a {
Action::Select(id) | Action::Enable(id) => mods.manager.packages.get(id),
_ => None,
});
**text = package
.map(|p| if p.running() { "ENABLED" } else { "DISABLED" })
.unwrap_or("")
.into();
color.0 = if package.is_some_and(|p| p.running()) {
Color::srgb(0.25, 1., 0.4)
} else {
Color::srgb(1., 0.25, 0.25)
};
}
let mut description = format!("{} / {}\n{}", selected + 1, entries.len(), menu.status);
if let Some(p) = menu
.id
.as_ref()
.and_then(|id| mods.manager.packages.get(id))
{
description += &format!(
"\n{} v{} by {}\n{}\n{}",
p.manifest.id,
p.manifest.version,
p.manifest.author,
p.manifest.description,
p.error.as_deref().unwrap_or("")
);
if let Action::Setting(_, key) = &entries[selected].1 {
description += &format!("\n{}", p.manifest.settings[key].description);
}
} else {
description += &mods
.manager
.diagnostics
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join("\n");
}
if let Some((key, value)) = &menu.editing {
description += &format!("\nEditing {key}: {value}_");
}
if description.chars().count() > 650 {
description = description.chars().take(650).collect::<String>()
+ "...\nFull errors are in the session log.";
}
***detail = description;
}
+512
View File
@@ -0,0 +1,512 @@
//! 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::*;
pub(crate) use menu::ModMenu;
pub(crate) use panel::EnabledPanel;
use serde_json::json;
use skate_mods::{Command, Manager};
use std::collections::BTreeMap;
#[derive(Resource)]
pub(crate) struct Mods {
pub manager: Manager,
animation_info: serde_json::Value,
trainer: Option<(String, skate_mods::TrainerTuning)>,
owned: BTreeMap<(String, String), Owned>,
generation: u64,
last_bail: bool,
last_state: u32,
last_grind: bool,
}
struct Owned {
entity: Entity,
mesh: Option<AssetId<Mesh>>,
material: Option<AssetId<StandardMaterial>>,
}
pub(crate) struct ModdingPlugin;
impl Plugin for ModdingPlugin {
fn build(&self, app: &mut App) {
let root = package_root();
if let Err(e) = std::fs::create_dir_all(&root) {
warn!("Cannot create mods folder {}: {e}", root.display());
}
info!("Mod packages folder: {}", root.display());
let settings = std::env::var_os("SKATE3_MOD_SETTINGS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
app.world()
.resource::<crate::config::Config>()
.asset_root
.parent()
.unwrap()
.join("settings/mods")
});
let frames = &app
.world()
.resource::<crate::physics::SkaterRuntime>()
.animation
.evaluator
.frames;
let slots: BTreeMap<_, _> = [
"R_ANTIC_OLLIE_N_0_INTO",
"R_ANTIC_OLLIE_N_0_CYC",
"R_ANTIC_360SHUVIT_N_0_CYC",
"360FLIP_D_HIGH_G",
"360FLIP_D_HIGH_A",
"360FLIP_D_LOW_G",
"360FLIP_D_LOW_A",
]
.into_iter()
.filter_map(|name| {
frames.clip(name).ok().map(|c| {
(
name,
json!({"fps":f32::from_bits(c.fps_bits),"frame_count":c.frames.len()}),
)
})
})
.collect();
let animation_info = json!({"bone_names":frames.bone_names,"slots":slots});
app.insert_resource(Mods {
animation_info,
trainer: None,
manager: Manager::new(root, settings),
owned: BTreeMap::new(),
generation: u64::MAX,
last_bail: false,
last_state: 0,
last_grind: false,
})
.init_resource::<ModMenu>()
.add_systems(
PreUpdate,
maintenance.after(crate::map_transition::MapTransitionSet),
)
.add_systems(
FixedUpdate,
fixed
.after(crate::app::SimulationSet::Physics)
.run_if(crate::graphics_menu::gameplay_active),
)
.add_systems(Update, update.after(crate::app::FrameSet::Animation));
vehicles::install(app);
network::install(app);
menu::install(app);
panel::install(app);
}
}
fn snapshot(world: &World) -> serde_json::Value {
let s = world.resource::<crate::physics::SkaterRuntime>();
let p = &s.player_input.physical;
let map = world.resource::<crate::map_transition::CurrentMap>();
let physics = world.resource::<crate::physics::GamePhysics>();
let keys: BTreeMap<_, _> = world
.resource::<ButtonInput<KeyCode>>()
.get_pressed()
.map(|k| (format!("{k:?}"), true))
.collect();
let (grinding, grind_name, grind_kind, grind_distance) = s.mod_grind();
let actions = *world
.resource::<crate::input::PublishedTickInput>()
.0
.actions()
.values();
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}},
"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})
}
fn maintenance(world: &mut World) {
let snap = snapshot(world);
world.resource_scope(|world, mut mods: Mut<Mods>| {
mods.manager.snapshot = snap;
let generation = world
.resource::<crate::map_transition::CurrentMap>()
.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);
}
world
.resource::<crate::physics::SkaterRuntime>()
.animation
.evaluator
.clear_mod_clips();
mods.generation = generation;
mods.manager.commands.clear();
let map = mods.manager.snapshot["map"].clone();
mods.manager
.dispatch("on_event", json!({"name":"world_changed","map":map}));
}
let events = std::mem::take(&mut world.resource_mut::<vehicles::Vehicles>().events);
for event in events { mods.manager.dispatch("on_event", event); }
mods.manager.scan(false);
apply(world, &mut mods);
});
}
fn fixed(world: &mut World) {
if world.resource::<crate::replay::Replay>().active {
return;
}
let snap = snapshot(world);
let dt = world.resource::<Time<Fixed>>().delta_secs_f64();
world.resource_scope(|world, mut mods: Mut<Mods>| {
mods.manager.snapshot = snap;
let bail = world
.resource::<crate::physics::GamePhysics>()
.board_wiping_out;
let state = world
.resource::<crate::physics::SkaterRuntime>()
.player_input
.physical
.state
.state_16;
if bail != mods.last_bail {
mods.last_bail = bail;
mods.manager
.dispatch("on_event", json!({"name":"bail_changed","bailing":bail}));
}
if state != mods.last_state {
let previous = mods.last_state;
mods.last_state = state;
mods.manager.dispatch(
"on_event",
json!({"name":"player_state_changed","previous":previous,"state":state}),
);
}
let grinding = world
.resource::<crate::physics::SkaterRuntime>()
.mod_grind()
.0;
if grinding != mods.last_grind {
mods.last_grind = grinding;
let grind = mods.manager.snapshot["player"]["grind"].clone();
mods.manager
.dispatch("on_event", json!({"name":"grind_changed","grind":grind}));
}
mods.manager.dispatch("on_fixed_update", json!({"dt":dt}));
apply(world, &mut mods);
});
}
fn update(world: &mut World) {
let snap = snapshot(world);
let paused =
snap["paused"].as_bool().unwrap_or(true) || snap["replay"].as_bool().unwrap_or(false);
let dt = world.resource::<Time<Real>>().delta_secs_f64().min(0.25);
world.resource_scope(|world, mut mods: Mut<Mods>| {
mods.manager.snapshot = snap;
if !paused {
mods.manager.dispatch("on_update", json!({"dt":dt}));
}
apply(world, &mut mods);
});
}
fn retire(world: &mut World, mods: &mut Mods, key: &(String, String)) {
if let Some(owned) = mods.owned.remove(key) {
world.despawn(owned.entity);
if let Some(id) = owned.mesh {
world.resource_mut::<Assets<Mesh>>().remove(id);
}
if let Some(id) = owned.material {
world.resource_mut::<Assets<StandardMaterial>>().remove(id);
}
}
}
fn apply(world: &mut World, mods: &mut Mods) {
let retired = std::mem::take(&mut mods.manager.retired);
if mods
.trainer
.as_ref()
.is_some_and(|(id, _)| retired.contains(id))
{
mods.trainer = None;
}
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>()
.animation
.evaluator
.remove_mod_clips(id);
}
let keys: Vec<_> = mods
.owned
.keys()
.filter(|(id, _)| retired.contains(id))
.cloned()
.collect();
for key in keys {
retire(world, mods, &key);
}
let mut batches = BTreeMap::<String, Vec<Command>>::new();
for (id, command) in std::mem::take(&mut mods.manager.commands) {
batches.entry(id).or_default().push(command);
}
for (id, commands) in batches {
if !mods.manager.packages.get(&id).is_some_and(|p| p.running()) {
continue;
}
let result = (|| {
let mut keys: std::collections::BTreeSet<_> = mods
.owned
.keys()
.filter(|(owner, _)| owner == &id)
.map(|(_, key)| key.clone())
.collect();
let mut teleport = None;
for c in &commands {
match c {
Command::Remove { key } => {
keys.remove(key);
}
Command::Overlay { key, .. } | Command::Cube { key, .. } => {
keys.insert(key.clone());
if keys.len() > 64 {
return Err("64 owned objects per mod maximum".into());
}
}
Command::Teleport { .. } => {
if teleport.is_some() {
return Err("Only one teleport per mod command batch".into());
}
teleport = Some(c.clone());
teleport_ready(world)?;
}
_ => {}
}
}
// Teleport is an irreversible native request. Submit only after all
// reversible installations succeed, so a later bad asset cannot leave it pending.
for command in commands {
if !matches!(command, Command::Teleport { .. }) {
apply_one(world, mods, &id, command)?;
}
}
if let Some(command) = teleport {
apply_one(world, mods, &id, command)?;
}
Ok::<(), String>(())
})();
if let Err(e) = result {
warn!("Lua mod {id}: {e}");
mods.manager.fail(&id, e);
vehicles::retire(world, &id);
if mods.trainer.as_ref().is_some_and(|(owner, _)| owner == &id) {
mods.trainer = None;
world.resource_mut::<crate::physics::GamePhysics>().trainer = Default::default();
}
world
.resource::<crate::physics::SkaterRuntime>()
.animation
.evaluator
.remove_mod_clips(&id);
let keys: Vec<_> = mods
.owned
.keys()
.filter(|(owner, _)| owner == &id)
.cloned()
.collect();
for key in keys {
retire(world, mods, &key);
}
}
}
// Stable text order independent of Bevy entity allocation or previous reloads.
let mut row = 0;
for owned in mods.owned.values() {
if let Some(mut node) = world.get_mut::<Node>(owned.entity) {
node.top = px(16. + row as f32 * 28.);
row += 1;
}
}
}
fn teleport_ready(world: &World) -> Result<(), String> {
if world.resource::<vehicles::Vehicles>().occupied() { return Err("Exit the vehicle before teleporting the skater".into()); }
if world
.resource::<crate::map_transition::MapTransition>()
.busy()
|| world.resource::<crate::replay::Replay>().active
{
return Err("Teleport unavailable during map loading or replay".into());
}
let s = world.resource::<crate::physics::SkaterRuntime>();
if s.player_input.physical.state.flag_69 != 0
|| s.player_input.physical.state.state_16 == 702
|| s.player_input.pending_teleport().is_some()
{
return Err("Teleport unavailable during reset".into());
}
Ok(())
}
fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> Result<(), String> {
let key = match &command {
Command::Cube { key, .. } | Command::Overlay { key, .. } | Command::Remove { key } => {
Some((id.to_owned(), key.clone()))
}
_ => None,
};
if let Some(key) = &key {
if !matches!(command, Command::Remove { .. })
&& !mods.owned.contains_key(key)
&& mods.owned.keys().filter(|(owner, _)| owner == id).count() >= 64
{
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)?;
}
Command::Trainer { tuning } => {
if mods.trainer.as_ref().is_some_and(|(owner, _)| owner != id) {
return Err("Native trainer controls are already owned by another mod".into());
}
mods.trainer = Some((id.to_owned(), tuning));
world.resource_mut::<crate::physics::GamePhysics>().trainer = tuning;
}
Command::Animation { path } => {
let root = &mods.manager.packages[id].root;
let bytes = skate_mods::read_bounded(root, &path, 4 * 1024 * 1024)?;
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
world
.resource::<crate::physics::SkaterRuntime>()
.animation
.evaluator
.install_mod_clips(id, text)?;
}
Command::Log { text } => info!("Lua [{id}]: {text}"),
Command::Remove { .. } => retire(world, mods, &key.unwrap()),
Command::Overlay { text, .. } => {
let key = key.unwrap();
if let Some(owned) = mods.owned.get(&key) {
if let Some(mut t) = world.get_mut::<Text>(owned.entity) {
**t = text;
return Ok(());
}
}
retire(world, mods, &key);
let entity = world
.spawn((
Text::new(text),
TextFont {
font_size: 19.,
..default()
},
TextColor(Color::WHITE),
GlobalZIndex(3),
Node {
position_type: PositionType::Absolute,
left: px(16.),
..default()
},
))
.id();
mods.owned.insert(
key,
Owned {
entity,
mesh: None,
material: None,
},
);
}
Command::Cube {
position,
size,
color,
..
} => {
let key = key.unwrap();
if let Some(owned) = mods.owned.get(&key) {
if owned.mesh.is_some() {
*world.get_mut::<Transform>(owned.entity).unwrap() =
Transform::from_translation(Vec3::from_array(position))
.with_scale(Vec3::from_array(size));
if let Some(material) = owned.material {
if let Some(m) = world
.resource_mut::<Assets<StandardMaterial>>()
.get_mut(material)
{
m.base_color = Color::srgb(color[0], color[1], color[2]);
}
}
return Ok(());
}
}
retire(world, mods, &key);
let mesh = world
.resource_mut::<Assets<Mesh>>()
.add(Cuboid::new(1., 1., 1.));
let material = world
.resource_mut::<Assets<StandardMaterial>>()
.add(StandardMaterial {
base_color: Color::srgb(color[0], color[1], color[2]),
..default()
});
let owned = Owned {
entity: world
.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
Transform::from_translation(Vec3::from_array(position))
.with_scale(Vec3::from_array(size)),
))
.id(),
mesh: Some(mesh.id()),
material: Some(material.id()),
};
mods.owned.insert(key, owned);
}
Command::Teleport {
position,
heading,
on_board,
} => {
if world
.resource::<crate::map_transition::MapTransition>()
.busy()
|| world.resource::<crate::replay::Replay>().active
{
return Err("Teleport unavailable during map loading or replay".into());
}
let mut s = world.resource_mut::<crate::physics::SkaterRuntime>();
if s.player_input.physical.state.flag_69 != 0
|| s.player_input.physical.state.state_16 == 702
|| s.player_input.pending_teleport().is_some()
{
return Err("Teleport unavailable during reset".into());
}
let (sin, cos) = heading.sin_cos();
let transform = [
[cos, 0., -sin, 0.],
[0., 1., 0., 0.],
[sin, 0., cos, 0.],
[position[0], position[1], position[2], 0.],
];
s.player_input
.request_teleport(transform)
.map_err(|e| format!("Teleport rejected: {e}"))?;
s.teleport_state.request_manual(transform, on_board);
}
}
Ok(())
}
pub(crate) fn package_root()->std::path::PathBuf {
std::env::var_os("SKATE3_MODS").map(std::path::PathBuf::from).unwrap_or_else(||
std::env::current_exe().ok().and_then(|p|p.parent().map(|p|p.join("mods"))).unwrap_or_else(||"mods".into()))
}
+335
View File
@@ -0,0 +1,335 @@
//! Generic SDK replication. Only validated data from matching local packages is applied.
use super::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
pub(super) enum Payload {
Cube {
key: String,
position: [f32; 3],
size: [f32; 3],
color: [f32; 3],
},
State {
key: String,
value: Value,
},
Tune {
key: String,
values: BTreeMap<String, Value>,
},
Car {
key: String,
state: vehicles::network::Car,
},
}
use serde_json::Value;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Record {
owner: String,
fingerprint: u64,
payload: Payload,
}
#[derive(Resource, Default)]
pub(crate) struct ModSync {
local: BTreeMap<(String, String), Payload>,
published: BTreeSet<String>,
remote: BTreeMap<(u64, String), (u32, String, String)>,
states: BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>,
identity: u64,
elapsed: f32,
pub status: String,
}
pub(super) fn install(app: &mut App) {
app.init_resource::<ModSync>()
.add_systems(Update, synchronize.after(vehicles::present));
}
pub(super) fn snapshot(world: &World) -> Value {
let (active, id, host) = world
.get_resource::<crate::multiplayer::Multiplayer>()
.map_or((false, 0, true), |n| n.mod_identity());
let sync = world.resource::<ModSync>();
json!({"active":active,"local_id":id.to_string(),"is_host":host,"states":sync.states,"status":sync.status})
}
fn wire_key(owner: &str, key: &str) -> String {
format!(
"{:016x}",
skate_net::hash(format!("{owner}/{key}").as_bytes())
)
}
pub(super) fn record(world: &mut World, owner: &str, command: &Command) -> Result<(), String> {
// Remote descriptors are installed directly and never pass back into this path.
let mut sync = world.resource_mut::<ModSync>();
let entry = match command {
Command::Cube {
key,
position,
size,
color,
} => Some((
format!("cube/{key}"),
Payload::Cube {
key: key.clone(),
position: *position,
size: *size,
color: *color,
},
)),
Command::Remove { key } | Command::Overlay { key, .. } => {
sync.local.remove(&(owner.into(), format!("cube/{key}")));
None
}
Command::VehicleRemove { key } => {
sync.local.remove(&(owner.into(), format!("tune/{key}")));
None
}
Command::NetworkState { key, value } => Some((
format!("state/{key}"),
Payload::State {
key: key.clone(),
value: value.clone(),
},
)),
Command::VehicleTune { key, tuning } => {
let slot = (owner.into(), format!("tune/{key}"));
let mut values = match sync.local.get(&slot) {
Some(Payload::Tune { values, .. }) => values.clone(),
_ => BTreeMap::new(),
};
if let Value::Object(fields) =
serde_json::to_value(tuning).map_err(|e| e.to_string())?
{
values.extend(fields.into_iter().filter(|(_, v)| !v.is_null()));
}
Some((
slot.1,
Payload::Tune {
key: key.clone(),
values,
},
))
}
_ => None,
};
if let Some((key, value)) = entry {
if sync.local.len() >= 128 && !sync.local.contains_key(&(owner.into(), key.clone())) {
return Err("128 shared mod records maximum".into());
}
sync.local.insert((owner.into(), key), value);
}
Ok(())
}
fn synchronize(world: &mut World) {
let dt = world.resource::<Time<Real>>().delta_secs();
world.resource_scope(|world, mut sync: Mut<ModSync>| {
sync.elapsed += dt;
if sync.elapsed < 0.05 {
return;
}
sync.elapsed = 0.;
let (_, identity, _) = world
.resource::<crate::multiplayer::Multiplayer>()
.mod_identity();
if identity != sync.identity {
sync.published.clear();
sync.identity = identity;
}
let packages: BTreeMap<_, _> = world
.resource::<Mods>()
.manager
.packages
.iter()
.filter(|(_, p)| p.running())
.map(|(id, p)| (id.clone(), (p.content_fingerprint(), p.root.clone())))
.collect();
sync.local
.retain(|(owner, _), _| packages.contains_key(owner));
let mut outgoing = sync.local.clone();
for (owner, key, state) in vehicles::network::capture(world) {
outgoing.insert((owner, format!("car/{key}")), Payload::Car { key, state });
}
let mut published = BTreeSet::new();
let mut failures = 0;
sync.states.clear();
for ((owner, key), payload) in outgoing {
let Some((fingerprint, _)) = packages.get(&owner) else {
continue;
};
if let Payload::State { key, value } = &payload {
if !value.is_null() {
sync.states
.entry(owner.clone())
.or_default()
.entry(identity.to_string())
.or_default()
.insert(key.clone(), value.clone());
}
}
let key = wire_key(&owner, &key);
let bytes = serde_json::to_vec(&Record {
owner,
fingerprint: *fingerprint,
payload,
})
.unwrap();
if identity != 0
&& !world
.resource_mut::<crate::multiplayer::Multiplayer>()
.publish_mod(&key, bytes)
{
failures += 1;
}
published.insert(key);
}
for key in sync.published.difference(&published) {
world
.resource_mut::<crate::multiplayer::Multiplayer>()
.publish_mod(key, vec![]);
}
sync.published = published;
let records = world
.resource::<crate::multiplayer::Multiplayer>()
.mod_records();
let mut seen = BTreeSet::new();
let mut mismatches = BTreeSet::new();
for (peer, wire, seq, bytes) in records {
let Ok(record) = serde_json::from_slice::<Record>(&bytes) else {
continue;
};
let Some((fingerprint, root)) = packages
.get(&record.owner)
.filter(|(f, _)| *f == record.fingerprint)
else {
mismatches.insert(record.owner);
continue;
};
let _ = fingerprint;
let remote_owner = format!("@{peer}:{}", record.owner);
let record_id = (peer, wire.clone());
let changed = sync
.remote
.get(&record_id)
.is_none_or(|(old, _, _)| *old != seq);
let mut object_key = String::new();
let result = match record.payload {
Payload::State { key, value } => {
if key.len() > 64 || serde_json::to_vec(&value).unwrap().len() > 512 {
continue;
}
if !value.is_null() {
sync.states
.entry(record.owner.clone())
.or_default()
.entry(peer.to_string())
.or_default()
.insert(key, value);
}
Ok(())
}
Payload::Cube {
key,
position,
size,
color,
} => {
object_key = format!("cube/{key}");
let c = Command::Cube {
key,
position,
size,
color,
};
if !c.validate() {
continue;
}
if changed {
world.resource_scope(|world, mut mods: Mut<Mods>| {
super::apply_one(world, &mut mods, &remote_owner, c)
})
} else {
Ok(())
}
}
Payload::Car { key, state } => {
object_key = format!("car/{key}");
if changed {
vehicles::network::receive(world, root, &remote_owner, &key, state)
} else {
Ok(())
}
}
Payload::Tune { key, values } => {
let Ok(tuning) = serde_json::from_value(serde_json::to_value(values).unwrap())
else {
continue;
};
let c = Command::VehicleTune { key, tuning };
if !c.validate() {
continue;
}
if changed {
vehicles::command(world, root, &remote_owner, c)
} else {
Ok(())
}
}
};
if result.is_ok() {
seen.insert(record_id.clone());
sync.remote
.insert(record_id, (seq, remote_owner, object_key));
}
}
let removed: Vec<_> = sync
.remote
.keys()
.filter(|id| !seen.contains(*id))
.cloned()
.collect();
for id in removed {
if let Some((_, owner, key)) = sync.remote.remove(&id) {
if let Some(key) = key.strip_prefix("cube/") {
world.resource_scope(|world, mut mods: Mut<Mods>| {
super::retire(world, &mut mods, &(owner.clone(), key.into()))
});
}
if let Some(key) = key.strip_prefix("car/") {
vehicles::network::remove(world, &owner, key);
}
}
}
// Remote lifecycle messages are observations, not local script commands.
world
.resource_mut::<vehicles::Vehicles>()
.events
.retain(|e| !e["owner"].as_str().is_some_and(|o| o.starts_with('@')));
sync.status = if failures > 0 {
format!("Mod network limit reached: {failures} records")
} else if !mismatches.is_empty() {
format!(
"Mods missing, disabled or different: {}",
mismatches.into_iter().collect::<Vec<_>>().join(", ")
)
} else {
format!(
"Matching mods synchronized | {} remote records",
sync.remote.len()
)
};
});
}
pub(super) fn retire(world: &mut World, owner: &str) {
world
.resource_mut::<ModSync>()
.local
.retain(|(id, _), _| id != owner);
}
pub(super) fn clear(world: &mut World) {
let mut s = world.resource_mut::<ModSync>();
s.local.clear();
s.remote.clear();
s.states.clear();
}
+754
View File
@@ -0,0 +1,754 @@
//! Independent, draggable enabled-mod windows. Positions survive closing Escape.
use super::{ModMenu, Mods};
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use std::collections::BTreeMap;
mod scrollbar;
#[derive(Default)]
struct Layout {
position: Vec2,
size: Vec2,
collapsed: bool,
selected: usize,
status: String,
reveal_selected: bool,
}
#[derive(Resource, Default)]
pub(crate) struct EnabledPanel {
pub focused: bool,
active: Option<String>,
layouts: BTreeMap<String, Layout>,
drag: Option<(String, Vec2)>,
resize: Option<(String, Vec2)>,
scroll_drag: Option<(String, f32)>,
signature: Vec<(String, Vec<String>)>,
}
impl EnabledPanel {
pub fn dragging(&self) -> bool {
self.drag.is_some() || self.resize.is_some() || self.scroll_drag.is_some()
}
}
#[derive(Component)]
struct Root(String);
#[derive(Component)]
struct Body(String);
#[derive(Component)]
struct Viewport(String);
#[derive(Component)]
struct Header(String);
#[derive(Component)]
struct ResizeGrip(String);
#[derive(Component)]
struct Row(String, usize);
#[derive(Component)]
struct Label(String, usize);
#[derive(Component)]
struct ValueLabel(String, usize);
#[derive(Component)]
struct Hint(String);
#[derive(Component)]
struct CollapseLabel(String);
#[derive(Component, Clone)]
struct Action(String, Operation);
#[derive(Clone)]
enum Operation {
Adjust(usize, i32),
Collapse,
Configure,
}
pub(super) fn install(app: &mut App) {
app.init_resource::<EnabledPanel>()
.add_systems(
PreUpdate,
input
.after(crate::graphics_menu::MenuInput)
.before(crate::map_transition::MapTransitionSet),
)
.add_systems(Update, (sync, draw, scroll, scrollbar::update).chain());
}
fn button(parent: &mut ChildSpawnerCommands, text: &str, action: Action) {
parent
.spawn((
Button,
action,
Node {
min_width: px(28.),
height: px(28.),
padding: UiRect::horizontal(px(5.)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
))
.with_children(|p| {
p.spawn((
Text::new(text),
TextFont {
font_size: 16.,
..default()
},
TextColor(Color::WHITE),
));
});
}
fn sync(
mut commands: Commands,
mods: Res<Mods>,
mut panel: ResMut<EnabledPanel>,
roots: Query<Entity, With<Root>>,
) {
let signature: Vec<_> = mods
.manager
.packages
.iter()
.filter(|(_, p)| p.running())
.map(|(id, p)| {
(
id.clone(),
p.manifest.settings.keys().cloned().collect::<Vec<_>>(),
)
})
.collect();
if signature == panel.signature {
return;
}
for root in &roots {
commands.entity(root).despawn();
}
panel.signature = signature.clone();
panel.drag = None;
panel.resize = None;
panel.scroll_drag = None;
if !signature
.iter()
.any(|(id, _)| panel.active.as_ref() == Some(id))
{
panel.active = signature.first().map(|(id, _)| id.clone());
panel.focused = false;
}
for (index, (id, settings)) in signature.iter().enumerate() {
panel.layouts.entry(id.clone()).or_insert_with(|| Layout {
size: Vec2::new(360., 760.),
position: Vec2::new(16. + index as f32 * 22., 64. + index as f32 * 34.),
..default()
});
let name = &mods.manager.packages[id].manifest.name;
commands
.spawn((
Root(id.clone()),
GlobalZIndex(12),
Node {
display: Display::None,
position_type: PositionType::Absolute,
width: px(360.),
height: px(760.),
max_width: percent(95),
padding: UiRect::all(px(10.)),
row_gap: px(8.),
flex_direction: FlexDirection::Column,
border_radius: BorderRadius::all(px(9.)),
..default()
},
BackgroundColor(Color::srgb(0.035, 0.055, 0.08)),
))
.with_children(|window| {
window
.spawn(Node {
width: percent(100),
column_gap: px(5.),
..default()
})
.with_children(|bar| {
bar.spawn((
Button,
Header(id.clone()),
Node {
flex_grow: 1.,
min_width: px(0.),
padding: UiRect::all(px(5.)),
..default()
},
BackgroundColor(Color::srgb(0.08, 0.18, 0.20)),
))
.with_children(|title| {
title.spawn((
Text::new(name),
TextFont {
font_size: 17.,
..default()
},
TextColor(Color::srgb(0.25, 1., 0.4)),
));
});
bar.spawn((
Button,
Action(id.clone(), Operation::Collapse),
Node {
width: px(30.),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
))
.with_children(|b| {
b.spawn((
CollapseLabel(id.clone()),
Text::new("-"),
TextFont {
font_size: 18.,
..default()
},
TextColor(Color::WHITE),
));
});
});
window
.spawn((
Body(id.clone()),
Node {
flex_grow: 1.,
min_height: px(0.),
flex_direction: FlexDirection::Column,
row_gap: px(6.),
..default()
},
))
.with_children(|body| {
body.spawn(Node {
flex_grow: 1.,
flex_basis: px(0.),
min_height: px(0.),
column_gap: px(6.),
..default()
})
.with_children(|area| {
area.spawn((
Viewport(id.clone()),
ScrollPosition::default(),
Node {
flex_grow: 1.,
flex_basis: px(0.),
min_height: px(0.),
min_width: px(0.),
height: percent(100),
overflow: Overflow::scroll_y(),
flex_direction: FlexDirection::Column,
row_gap: px(6.),
..default()
},
))
.with_children(|list| {
for i in 0..settings.len() {
list.spawn((
Row(id.clone(), i),
Node {
align_items: AlignItems::Center,
column_gap: px(4.),
min_height: px(34.),
flex_shrink: 0.,
..default()
},
BackgroundColor(Color::srgb(0.08, 0.11, 0.15)),
))
.with_children(|row| {
row.spawn((
Label(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
Node {
flex_grow: 1.,
width: px(132.),
..default()
},
));
button(
row,
"-",
Action(id.clone(), Operation::Adjust(i, -1)),
);
row.spawn((
ValueLabel(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
Node {
width: px(51.),
..default()
},
));
button(
row,
"+",
Action(id.clone(), Operation::Adjust(i, 1)),
);
});
}
});
scrollbar::spawn(area, id);
});
body.spawn(Node {
column_gap: px(6.),
..default()
})
.with_children(|p| {
button(p, "Manage mod", Action(id.clone(), Operation::Configure));
});
body.spawn((
Hint(id.clone()),
Text::new(""),
TextFont {
font_size: 12.,
..default()
},
TextColor(Color::srgb(0.65, 0.85, 0.85)),
));
body.spawn((
Button,
ResizeGrip(id.clone()),
Node {
align_self: AlignSelf::FlexEnd,
width: px(28.),
height: px(22.),
flex_shrink: 0.,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
))
.with_children(|grip| {
grip.spawn((
Text::new("//"),
TextFont {
font_size: 18.,
..default()
},
TextColor(Color::WHITE),
));
});
});
});
}
}
fn input(
mut panel: ResMut<EnabledPanel>,
pause: Res<crate::graphics_menu::Menu>,
mut menu: ResMut<ModMenu>,
custom: Res<crate::customiser::Customiser>,
mut mods: ResMut<Mods>,
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
nav: Res<crate::customiser::Navigation>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
grips: Query<(&Interaction, &ResizeGrip), Changed<Interaction>>,
headers: Query<(&Interaction, &Header), Changed<Interaction>>,
buttons: Query<(&Interaction, &Action), Changed<Interaction>>,
) {
if !pause.open || menu.open || custom.open {
panel.focused = false;
panel.drag = None;
panel.resize = None;
panel.scroll_drag = None;
return;
}
let ids: Vec<_> = mods
.manager
.packages
.iter()
.filter(|(_, p)| p.running())
.map(|(id, _)| id.clone())
.collect();
if ids.is_empty() {
panel.focused = false;
panel.drag = None;
panel.resize = None;
panel.scroll_drag = None;
return;
}
if keys.just_pressed(KeyCode::Tab) || nav.pressed & 0x4000 != 0 {
let next = if panel.focused {
panel
.active
.as_ref()
.and_then(|id| ids.iter().position(|s| s == id))
.map(|i| i + 1)
.unwrap_or(0)
} else {
0
};
panel.focused = next < ids.len();
if panel.focused {
panel.active = Some(ids[next].clone());
}
}
if !mouse.pressed(MouseButton::Left) {
panel.drag = None;
panel.resize = None;
panel.scroll_drag = None;
}
for (interaction, header) in &headers {
if *interaction == Interaction::Pressed && mouse.pressed(MouseButton::Left) {
if let (Some(cursor), Some(layout)) =
(window.cursor_position(), panel.layouts.get(&header.0))
{
panel.drag = Some((header.0.clone(), cursor - fitted_position(layout, &window)));
panel.active = Some(header.0.clone());
panel.focused = true;
}
}
}
for (interaction, grip) in &grips {
if *interaction == Interaction::Pressed && mouse.pressed(MouseButton::Left) {
if let (Some(cursor), Some(layout)) =
(window.cursor_position(), panel.layouts.get(&grip.0))
{
let size = fit_size(layout.size, &window);
let position = fitted_position(layout, &window);
panel.layouts.get_mut(&grip.0).unwrap().position = position;
panel.resize = Some((grip.0.clone(), size - cursor));
panel.drag = None;
panel.active = Some(grip.0.clone());
panel.focused = true;
}
}
}
if let (Some((id, offset)), Some(cursor)) = (panel.resize.clone(), window.cursor_position()) {
if let Some(layout) = panel.layouts.get_mut(&id) {
layout.size = fit_size(cursor + offset, &window)
.min(Vec2::new(window.width(), window.height()) - layout.position);
}
}
if let (Some((id, offset)), Some(cursor)) = (panel.drag.clone(), window.cursor_position()) {
if let Some(layout) = panel.layouts.get_mut(&id) {
layout.position = (cursor - offset).clamp(
Vec2::ZERO,
Vec2::new(
(window.width() - fit_size(layout.size, &window).x).max(0.),
(window.height()
- if layout.collapsed {
48.
} else {
fit_size(layout.size, &window).y
})
.max(0.),
),
);
}
}
let mut action = None;
if panel.focused {
if let Some(id) = panel.active.clone() {
if let (Some(p), Some(layout)) =
(mods.manager.packages.get(&id), panel.layouts.get_mut(&id))
{
let count = p.manifest.settings.len();
if count > 0 {
layout.selected = layout.selected.min(count - 1);
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
layout.selected = (layout.selected + count - 1) % count;
layout.reveal_selected = true;
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
layout.selected = (layout.selected + 1) % count;
layout.reveal_selected = true;
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some(Action(id.clone(), Operation::Adjust(layout.selected, -1)));
}
if keys.just_pressed(KeyCode::ArrowRight)
|| keys.just_pressed(KeyCode::Enter)
|| nav.pressed & (8 | 0x1000) != 0
{
action = Some(Action(id.clone(), Operation::Adjust(layout.selected, 1)));
}
}
}
}
}
for (interaction, button) in &buttons {
if *interaction == Interaction::Pressed {
action = Some(button.clone());
panel.active = Some(button.0.clone());
panel.focused = true;
}
}
let Some(Action(id, operation)) = action else {
return;
};
let Some(p) = mods.manager.packages.get(&id) else {
return;
};
if !p.running() {
return;
}
let Some(layout) = panel.layouts.get_mut(&id) else {
return;
};
match operation {
Operation::Collapse => {
layout.collapsed = !layout.collapsed;
}
Operation::Configure => menu.configure(id),
Operation::Adjust(row, direction) => {
if layout.collapsed {
return;
}
let selected = row;
let Some((key, s)) = p.manifest.settings.iter().nth(selected) else {
return;
};
let key = key.clone();
layout.selected = selected;
let v = &p.settings[&key];
let next = match s.kind.as_str() {
"number" => Some(serde_json::json!(
(v.as_f64().unwrap() + direction as f64 * s.step.unwrap())
.clamp(s.min.unwrap(), s.max.unwrap())
)),
"boolean" => Some(serde_json::json!(!v.as_bool().unwrap())),
"choice" => {
let i = s
.choices
.iter()
.position(|c| Some(c.as_str()) == v.as_str())
.unwrap_or(0);
Some(serde_json::json!(
s.choices
[(i as i32 + direction).rem_euclid(s.choices.len() as i32) as usize]
))
}
_ => {
menu.configure(id.clone());
None
}
};
if let Some(v) = next {
layout.status = mods.manager.setting(&id, &key, v).err().unwrap_or_default();
}
}
}
}
fn draw(
panel: Res<EnabledPanel>,
pause: Res<crate::graphics_menu::Menu>,
menu: Res<ModMenu>,
custom: Res<crate::customiser::Customiser>,
mods: Res<Mods>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
mut roots: Query<(&Root, &mut Node, &mut GlobalZIndex)>,
mut bodies: Query<(&Body, &mut Node), Without<Root>>,
mut rows: Query<(&Row, &mut Node, &mut BackgroundColor), (Without<Root>, Without<Body>)>,
mut text: Query<(
&mut Text,
Option<&Label>,
Option<&ValueLabel>,
Option<&Hint>,
Option<&CollapseLabel>,
)>,
) {
let visible = pause.open && !menu.open && !custom.open;
for (root, mut node, mut z) in &mut roots {
if let Some(layout) = panel.layouts.get(&root.0) {
node.display = if visible
&& mods
.manager
.packages
.get(&root.0)
.is_some_and(|p| p.running())
{
Display::Flex
} else {
Display::None
};
let size = fit_size(layout.size, &window);
node.width = px(size.x);
node.height = if layout.collapsed {
Val::Auto
} else {
px(size.y)
};
node.left = px(layout
.position
.x
.clamp(0., (window.width() - size.x).max(0.)));
node.top = px(layout.position.y.clamp(
0.,
(window.height() - if layout.collapsed { 48. } else { size.y }).max(0.),
));
z.0 = if panel.active.as_ref() == Some(&root.0) {
14
} else {
12
};
}
}
for (body, mut node) in &mut bodies {
node.display = if panel.layouts.get(&body.0).is_some_and(|l| !l.collapsed) {
Display::Flex
} else {
Display::None
};
}
for (row, mut node, mut color) in &mut rows {
let selected = panel.layouts.get(&row.0).map_or(0, |l| l.selected);
let index = row.1;
node.display = if mods
.manager
.packages
.get(&row.0)
.is_some_and(|p| index < p.manifest.settings.len())
{
Display::Flex
} else {
Display::None
};
color.0 = if panel.focused && panel.active.as_ref() == Some(&row.0) && index == selected {
Color::srgb(0.10, 0.30, 0.34)
} else {
Color::srgb(0.08, 0.11, 0.15)
};
}
for (mut t, label, value, hint, collapse) in &mut text {
if let Some(c) = collapse {
**t = if panel.layouts.get(&c.0).is_some_and(|l| l.collapsed) {
"+"
} else {
"-"
}
.into();
}
if let Some(h) = hint {
if let Some(l) = panel.layouts.get(&h.0) {
**t = format!(
"Drag title to move | Drag // to resize\nWheel or drag scrollbar | Tab / X: focus\n{}",
l.status
);
}
}
let field = label
.map(|l| (&l.0, l.1, false))
.or_else(|| value.map(|v| (&v.0, v.1, true)));
if let Some((id, row, is_value)) = field {
let setting = mods
.manager
.packages
.get(id)
.and_then(|p| p.manifest.settings.iter().nth(row).map(|(k, s)| (p, k, s)));
**t = setting
.map(|(p, key, s)| {
if !is_value {
s.label.clone()
} else {
let v = &p.settings[key];
v.as_f64().map(|n| format!("{n:.2}")).unwrap_or_else(|| {
v.as_str()
.map(str::to_owned)
.unwrap_or_else(|| v.to_string())
})
}
})
.unwrap_or_default();
}
}
}
// Scroll only the uppermost mod window under the pointer. Use computed UI
// geometry so DPI scaling, dragging and wrapped setting labels remain correct.
fn scroll(
mut wheel: MessageReader<MouseWheel>,
mut panel: ResMut<EnabledPanel>,
pause: Res<crate::graphics_menu::Menu>,
menu: Res<ModMenu>,
custom: Res<crate::customiser::Customiser>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
roots: Query<(&Root, &ComputedNode, &UiGlobalTransform)>,
mut lists: Query<(
&Viewport,
&ComputedNode,
&UiGlobalTransform,
&mut ScrollPosition,
)>,
rows: Query<(&Row, &ComputedNode, &UiGlobalTransform)>,
) {
let delta: f32 = wheel
.read()
.map(|event| match event.unit {
MouseScrollUnit::Line => event.y * 40.,
MouseScrollUnit::Pixel => event.y,
})
.sum();
if !pause.open || menu.open || custom.open || panel.dragging() {
return;
}
let hovered = window.physical_cursor_position().and_then(|cursor| {
roots
.iter()
.filter(|(_, node, transform)| node.contains_point(**transform, cursor))
.max_by_key(|(root, node, _)| {
(panel.active.as_ref() == Some(&root.0), node.stack_index)
})
.map(|(root, _, _)| root.0.clone())
});
for (viewport, node, transform, mut position) in &mut lists {
let Some(layout) = panel.layouts.get_mut(&viewport.0) else {
continue;
};
if layout.collapsed {
continue;
}
if hovered.as_ref() == Some(&viewport.0) && delta != 0. {
position.0.y -= delta;
layout.reveal_selected = false;
}
if layout.reveal_selected && node.size().y > 0. {
if let Some((_, row_node, row_transform)) = rows
.iter()
.find(|(row, _, _)| row.0 == viewport.0 && row.1 == layout.selected)
{
let center = transform
.inverse()
.transform_point2(row_transform.translation);
let top = center.y - row_node.size().y / 2. + node.size().y / 2.;
let bottom = top + row_node.size().y;
let shift = if top < 0. {
top
} else {
(bottom - node.size().y).max(0.)
};
position.0.y += shift * node.inverse_scale_factor;
layout.reveal_selected = false;
}
}
let max = ((node.content_size().y - node.size().y) * node.inverse_scale_factor).max(0.);
position.0.y = position.0.y.clamp(0., max);
}
}
fn fit_size(size: Vec2, window: &Window) -> Vec2 {
let maximum = Vec2::new(
(window.width() - 16.).max(1.),
(window.height() - 32.).max(1.),
);
size.clamp(Vec2::new(320., 260.).min(maximum), maximum)
}
fn fitted_position(layout: &Layout, window: &Window) -> Vec2 {
let mut size = fit_size(layout.size, window);
if layout.collapsed {
size.y = 48.;
}
layout.position.clamp(
Vec2::ZERO,
(Vec2::new(window.width(), window.height()) - size).max(Vec2::ZERO),
)
}
@@ -0,0 +1,130 @@
//! A scrollbar tied to the actual settings viewport, including resize and wheel changes.
use super::*;
#[derive(Component)]
pub(super) struct Track(String);
#[derive(Component)]
pub(super) struct Thumb(String);
pub(super) fn spawn(area: &mut ChildSpawnerCommands, id: &str) {
area.spawn((
Button,
Track(id.into()),
Node {
width: px(14.),
height: percent(100),
flex_shrink: 0.,
border_radius: BorderRadius::all(px(6.)),
..default()
},
BackgroundColor(Color::srgb(0.07, 0.11, 0.16)),
))
.with_children(|track| {
track.spawn((
Thumb(id.into()),
bevy::ui::FocusPolicy::Pass,
Node {
position_type: PositionType::Absolute,
left: px(2.),
right: px(2.),
top: px(0.),
height: percent(100),
border_radius: BorderRadius::all(px(5.)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.65, 0.68)),
));
});
}
pub(super) fn update(
mut panel: ResMut<EnabledPanel>,
pause: Res<crate::graphics_menu::Menu>,
menu: Res<ModMenu>,
custom: Res<crate::customiser::Customiser>,
mouse: Res<ButtonInput<MouseButton>>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
tracks: Query<(&Track, &Interaction, &ComputedNode, &UiGlobalTransform)>,
mut thumbs: Query<(&Thumb, &mut Node, &mut BackgroundColor)>,
mut lists: Query<(&Viewport, &ComputedNode, &mut ScrollPosition)>,
) {
if !mouse.pressed(MouseButton::Left) || !pause.open || menu.open || custom.open {
panel.scroll_drag = None;
}
if !pause.open || menu.open || custom.open || panel.drag.is_some() || panel.resize.is_some() {
return;
}
for (track, interaction, geometry, transform) in &tracks {
if !panel
.layouts
.get(&track.0)
.is_some_and(|layout| !layout.collapsed)
{
continue;
}
let Some((_, viewport, mut scroll)) = lists.iter_mut().find(|(v, _, _)| v.0 == track.0)
else {
continue;
};
let height = geometry.size().y * geometry.inverse_scale_factor;
if height <= 0. {
continue;
}
let visible = viewport.size().y;
let content = viewport.content_size().y.max(visible);
let maximum = ((content - visible) * viewport.inverse_scale_factor).max(0.);
let thumb_height = (height * visible / content.max(1.)).max(24.).min(height);
let travel = height - thumb_height;
scroll.0.y = scroll.0.y.clamp(0., maximum);
let mut top = if maximum > 0. {
travel * scroll.0.y / maximum
} else {
0.
};
if let Some(cursor) = window.physical_cursor_position() {
let y = (transform.inverse().transform_point2(cursor).y + geometry.size().y / 2.)
* geometry.inverse_scale_factor;
if maximum > 0.
&& travel > 0.
&& *interaction == Interaction::Pressed
&& mouse.just_pressed(MouseButton::Left)
{
let offset = if (top..=top + thumb_height).contains(&y) {
(y - top) / thumb_height
} else {
0.5
};
panel.scroll_drag = Some((track.0.clone(), offset));
panel.active = Some(track.0.clone());
panel.focused = true;
}
if let Some((id, offset)) = &panel.scroll_drag {
if id == &track.0 && travel > 0. {
top = (y - offset * thumb_height).clamp(0., travel);
scroll.0.y = top / travel * maximum;
if let Some(layout) = panel.layouts.get_mut(&track.0) {
layout.reveal_selected = false;
}
}
}
}
if let Some((_, mut node, mut color)) =
thumbs.iter_mut().find(|(thumb, _, _)| thumb.0 == track.0)
{
node.top = px(top);
node.height = px(thumb_height);
color.0 = if maximum <= 0. {
Color::srgb(0.16, 0.24, 0.28)
} else if *interaction != Interaction::None
|| panel
.scroll_drag
.as_ref()
.is_some_and(|(id, _)| id == &track.0)
{
Color::srgb(0.35, 0.9, 0.85)
} else {
Color::srgb(0.25, 0.65, 0.68)
};
}
}
}
@@ -0,0 +1,156 @@
use bevy::prelude::*;
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Default)]
pub(super) struct Clips(pub BTreeMap<String, Clip>);
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
version: u32,
bone_names: Vec<String>,
clips: BTreeMap<String, Clip>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Clip {
fps: f32,
frames: Vec<Vec<[f32; 16]>>,
}
impl Clips {
pub fn load(
root: &std::path::Path,
definition: &skate_vehicles::VehicleDefinition,
names: &[String],
) -> Result<Self, String> {
let Some(path) = &definition.animations.file else {
return Ok(Self::default());
};
let bytes = skate_mods::read_bounded(root, path, 16 * 1024 * 1024)?;
let file: File = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if file.version != 1 || file.bone_names != names || file.clips.len() > 32 {
return Err("Vehicle animation version/skeleton mismatch".into());
}
for clip in file.clips.values() {
if !clip.fps.is_finite()
|| !(1. ..=120.).contains(&clip.fps)
|| clip.frames.is_empty()
|| clip.frames.len() > 3600
{
return Err("Invalid vehicle clip timing".into());
}
for frame in &clip.frames {
if frame.len() != names.len() {
return Err("Vehicle clip missing bones".into());
}
for values in frame {
let m = Mat4::from_cols_array(values);
let (scale, rotation, translation) = m.to_scale_rotation_translation();
if !m.is_finite()
|| m.determinant() <= 0.
|| !rotation.is_finite()
|| scale.min_element() < 0.9
|| scale.max_element() > 1.1
|| translation.abs().max_element() > 20.
|| (m.w_axis.w - 1.).abs() > 0.001
|| m.x_axis.w.abs() + m.y_axis.w.abs() + m.z_axis.w.abs() > 0.001
{
return Err("Invalid vehicle bone transform".into());
}
}
}
}
let a = &definition.animations;
for name in [
&a.enter,
&a.exit,
&a.drive,
&a.idle,
&a.reverse,
&a.brake,
&a.steer_left,
&a.steer_right,
]
.into_iter()
.flatten()
{
if !file.clips.contains_key(name) {
return Err(format!("Missing vehicle animation {name}"));
}
}
Ok(Self(file.clips))
}
pub fn duration(&self, name: Option<&String>) -> f32 {
name.and_then(|n| self.0.get(n))
.map_or(0., |c| c.frames.len() as f32 / c.fps)
}
pub fn pose(&self, name: Option<&String>, time: f32, looping: bool) -> Option<Vec<Mat4>> {
let c = self.0.get(name?)?;
let at = time * c.fps;
let at = if looping {
at % c.frames.len() as f32
} else {
at.min((c.frames.len() - 1) as f32)
};
let i = at.floor() as usize;
let j = if looping {
(i + 1) % c.frames.len()
} else {
(i + 1).min(c.frames.len() - 1)
};
Some(
c.frames[i]
.iter()
.zip(&c.frames[j])
.map(|(a, b)| {
let (sa, ra, ta) = Mat4::from_cols_array(a).to_scale_rotation_translation();
let (sb, rb, tb) = Mat4::from_cols_array(b).to_scale_rotation_translation();
Mat4::from_scale_rotation_translation(
sa.lerp(sb, at.fract()),
ra.slerp(rb, at.fract()),
ta.lerp(tb, at.fract()),
)
})
.collect(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vehicle_clips_validate_and_play_once_or_loop() {
let root = std::env::temp_dir().join(format!("skate-vehicle-clips-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let a = Mat4::IDENTITY.to_cols_array();
let b = Mat4::from_translation(Vec3::X).to_cols_array();
let file = serde_json::json!({"version":1,"bone_names":["HIPS"],"clips":{"enter":{"fps":1,"frames":[[a],[b]]}}});
std::fs::write(root.join("rider.json"), file.to_string()).unwrap();
let mut definition = skate_vehicles::VehicleDefinition::default();
definition.animations.file = Some("rider.json".into());
definition.animations.enter = Some("enter".into());
let clips = Clips::load(&root, &definition, &["HIPS".into()]).unwrap();
let name = definition.animations.enter.as_ref();
assert_eq!(clips.duration(name), 2.);
assert!((clips.pose(name, 0.5, false).unwrap()[0].w_axis.x - 0.5).abs() < 0.001);
assert_eq!(clips.pose(name, 3., false).unwrap()[0].w_axis.x, 1.);
assert_eq!(clips.pose(name, 2., true).unwrap()[0].w_axis.x, 0.);
assert!(Clips::load(&root, &definition, &["WRONG".into()]).is_err());
definition.animations.drive = Some("missing".into());
assert!(Clips::load(&root, &definition, &["HIPS".into()]).is_err());
definition.animations.drive = None;
let mut invalid = file;
invalid["clips"]["enter"]["frames"][0][0][0] = serde_json::json!(0);
std::fs::write(root.join("rider.json"), invalid.to_string()).unwrap();
assert!(Clips::load(&root, &definition, &["HIPS".into()]).is_err());
// Remove only this fixture's known file; do not recursively delete a computed directory.
std::fs::remove_file(root.join("rider.json")).unwrap();
let _ = std::fs::remove_dir(root);
}
#[test]
fn missing_animation_uses_immediate_hidden_rider_fallback() {
let clips = Clips::default();
assert_eq!(clips.duration(None), 0.);
assert!(clips.pose(None, 0., false).is_none());
}
}
@@ -0,0 +1,43 @@
//! 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(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.));
}
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);}
}
}
@@ -0,0 +1,48 @@
//! Original continuous exhaust synthesizer.
use bevy::{audio::Source, prelude::*};
use std::time::Duration;
#[derive(Asset, TypePath)]
pub struct Engine;
pub struct Exhaust { phase: f32, noise: u32, filtered: f32 }
impl Iterator for Exhaust {
type Item = f32;
fn next(&mut self) -> Option<f32> {
self.phase = (self.phase + 80. / 48000.) % 1.;
self.noise = self.noise.wrapping_mul(1664525).wrapping_add(1013904223);
let noise = self.noise as f32 / u32::MAX as f32 * 2. - 1.;
self.filtered += 0.12 * (noise - self.filtered);
let p = self.phase * std::f32::consts::TAU;
let pulse = p.sin() * 0.48 + (p * 2.).sin() * 0.24
+ (p * 3.).sin() * 0.12 + (p * 5.).sin() * 0.06;
Some((pulse + self.filtered * 0.18) * 0.65)
}
}
impl Source for Exhaust {
fn current_frame_len(&self) -> Option<usize> { None }
fn channels(&self) -> u16 { 1 }
fn sample_rate(&self) -> u32 { 48000 }
fn total_duration(&self) -> Option<Duration> { None }
}
impl Decodable for Engine {
type DecoderItem = f32;
type Decoder = Exhaust;
fn decoder(&self) -> Exhaust { Exhaust { phase: 0., noise: 17, filtered: 0. } }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exhaust_is_bounded_continuous_and_not_silent() {
let mut source = Engine.decoder();
let mut last = 0.;
let mut energy = 0.;
for _ in 0..48000 {
let sample = source.next().unwrap();
assert!(sample.is_finite() && sample.abs() < 0.8);
assert!((sample - last).abs() < 0.1);
energy += sample * sample;
last = sample;
}
assert!(energy > 100.);
}
}
@@ -0,0 +1,65 @@
//! One render-time sample for every visible part of a fixed-step vehicle.
use bevy::prelude::*;
#[derive(Clone)]
pub(super) struct Motion {
pub body: Transform,
pub wheels: Vec<Transform>,
}
fn blend(a: Transform, b: Transform, alpha: f32) -> Transform {
Transform {
translation: a.translation.lerp(b.translation, alpha),
rotation: a.rotation.normalize().slerp(b.rotation.normalize(), alpha),
scale: a.scale.lerp(b.scale, alpha),
}
}
impl Motion {
pub fn capture(sim: &skate_vehicles::Simulation, id: u64) -> Option<Self> {
let (p, q) = sim.pose(id)?;
let car = sim.vehicles.get(&id)?;
Some(Self {
body: Transform::from_translation(Vec3::from_array(p))
.with_rotation(Quat::from_array(q)),
wheels: car.controller.wheels().iter().map(|wheel| {
Transform::from_xyz(0., (car.definition.suspension_length
- wheel.raycast_info().suspension_length) / car.definition.model_scale, 0.)
.with_rotation(Quat::from_rotation_y(wheel.steering)
* Quat::from_rotation_x(-wheel.rotation))
}).collect(),
})
}
pub fn sample(&self, current: &Self, alpha: f32) -> Self {
let alpha = alpha.clamp(0., 1.);
Self {
body: blend(self.body, current.body, alpha),
wheels: current.wheels.iter().enumerate().map(|(i, &now)| {
self.wheels.get(i).map_or(now, |&before| blend(before, now, alpha))
}).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subframes_move_continuously_and_keep_seat_attached() {
let before = Motion { body: Transform::IDENTITY, wheels: vec![Transform::IDENTITY] };
let after = Motion {
body: Transform::from_xyz(1., 0., 0.).with_rotation(Quat::from_rotation_y(0.2)),
wheels: vec![Transform::from_xyz(0., 0.1, 0.)],
};
let seat = Vec3::new(0., 0.18, -0.2);
for index in 0..=4 {
let alpha = index as f32 / 4.;
let sample = before.sample(&after, alpha);
assert!((sample.body.translation.x - alpha).abs() < 1e-6);
assert!((sample.wheels[0].translation.y - alpha * 0.1).abs() < 1e-6);
let rider = sample.body.transform_point(seat);
assert!((rider.distance(sample.body.translation) - seat.length()).abs() < 1e-6);
}
}
}
@@ -0,0 +1,746 @@
//! Native vehicle host: isolated Rapier world, mod ownership and driver lifecycle.
mod animations;
pub(crate) mod network;
mod audio;
mod engine_sound;
mod interpolation;
use bevy::prelude::*;
use serde_json::{Value, json};
use skate_mods::Command;
use skate_vehicles::{Controls, Simulation, VehicleDefinition};
use std::collections::BTreeMap;
#[derive(Component, Clone)]
struct WheelVisual {
vehicle: u64,
index: usize,
rest: Transform,
}
struct Instance {
id: u64,
entity: Entity,
scene: Handle<Scene>,
clips: animations::Clips,
last_control: f32,
definition_path: String,
}
struct Driver {
owner: String,
key: String,
phase: &'static str,
time: f32,
return_position: [f32; 3],
return_heading: f32,
}
#[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,
blend_time: f32,
steering_visual: f32,
crash_handoff: bool,
previous_motion: BTreeMap<u64, interpolation::Motion>,
rendered_motion: BTreeMap<u64, interpolation::Motion>,
}
impl Vehicles {
pub(crate) fn occupied(&self) -> bool {
self.driver.is_some()
}
pub(super) fn player_pose(&self) -> Option<([f32; 3], f32)> {
let d = self.driver.as_ref()?;
let i = self.owned.get(&(d.owner.clone(), d.key.clone()))?;
let (p, q) = self.simulation.pose(i.id)?;
let q = Quat::from_array(q);
let forward = q * Vec3::Z;
let seat = Vec3::from_array(self.simulation.vehicles[&i.id].definition.seat);
Some((
(Vec3::from_array(p) + q * seat).to_array(),
forward.x.atan2(forward.z),
))
}
pub(crate) fn camera(&self) -> Option<Transform> {
let d = self.driver.as_ref()?;
let i = self.owned.get(&(d.owner.clone(), d.key.clone()))?;
let pose = self.rendered_motion.get(&i.id)?.body;
let q = pose.rotation;
let def = &self.simulation.vehicles[&i.id].definition;
let center = pose.translation + Vec3::Y * 0.5;
let forward = (q * Vec3::Z).with_y(0.).normalize_or_zero();
Some(
Transform::from_translation(
center - forward * def.camera_distance + Vec3::Y * def.camera_height,
)
.looking_at(center + forward, Vec3::Y),
)
}
}
pub(super) fn install(app: &mut App) {
audio::install(app);
app.init_resource::<Vehicles>()
.add_systems(
FixedUpdate,
tick.after(super::fixed)
.run_if(network::simulation_active),
)
.add_systems(
Update,
present
.after(crate::app::FrameSet::Animation)
.before(crate::camera::present),
);
}
pub(super) fn snapshot(world: &World) -> Value {
let v = world.resource::<Vehicles>();
let mut out = serde_json::Map::new();
for ((owner, key), instance) in &v.owned {
let Some((p, q)) = v.simulation.pose(instance.id) else {
continue;
};
let car = &v.simulation.vehicles[&instance.id];
let phase = v
.driver
.as_ref()
.filter(|d| d.owner == *owner && d.key == *key)
.map_or("parked", |d| d.phase);
out.entry(owner.clone()).or_insert(json!({})).as_object_mut().unwrap().insert(key.clone(),json!({"position":p,"rotation":q,"heading":({let f=Quat::from_array(q)*Vec3::Z;f.x.atan2(f.z)}),"speed":car.controller.current_vehicle_speed,"phase":phase,"occupied":phase!="parked","ready":world.resource::<AssetServer>().is_loaded_with_dependencies(instance.scene.id())}));
}
Value::Object(out)
}
fn event(v: &mut Vehicles, owner: &str, key: &str, name: &str) {
v.events.push(json!({"name":name,"owner":owner,"key":key}));
}
fn exit_now(world: &mut World, v: &mut Vehicles, forced: bool) -> Result<(), String> {
let Some(driver) = v.driver.take() else {
return Ok(());
};
let mut position = driver.return_position;
let mut heading = driver.return_heading;
if !forced {
if let Some(i) = v.owned.get(&(driver.owner.clone(), driver.key.clone())) {
if let Some((p, q)) = v.simulation.pose(i.id) {
let q = Quat::from_array(q);
let offset = Vec3::from_array(v.simulation.vehicles[&i.id].definition.exit);
let candidate = (Vec3::from_array(p) + q * offset).to_array();
if let Some(floor) = v.simulation.floor(candidate) {
position = [floor[0], floor[1] + 0.15, floor[2]];
let forward = q * Vec3::Z;
heading = forward.x.atan2(forward.z);
}
}
}
}
if let Some(i)=v.owned.get(&(driver.owner.clone(),driver.key.clone())) {v.simulation.set_occupied(i.id,false);}
v.pose = None;
for (entity, visibility) in v.hidden.drain(..) {
if let Some(mut current) = world.get_mut::<Visibility>(entity) {
*current = visibility;
}
}
let (sin, cos) = heading.sin_cos();
let matrix = [
[cos, 0., -sin, 0.],
[0., 1., 0., 0.],
[sin, 0., cos, 0.],
[position[0], position[1], position[2], 0.],
];
let mut skater = world.resource_mut::<crate::physics::SkaterRuntime>();
if skater.player_input.pending_teleport().is_none() {
skater
.player_input
.request_teleport(matrix)
.map_err(|e| e.to_string())?;
skater.teleport_state.request_manual(matrix, false);
}
event(v, &driver.owner, &driver.key, "vehicle_exited");
Ok(())
}
fn eject_now(world: &mut World, v: &mut Vehicles, ejection: skate_vehicles::Ejection) -> Result<(), String> {
let Some(driver) = v.driver.as_ref() else {return Ok(());};
let owner=driver.owner.clone(); let key=driver.key.clone();
let id=v.owned[&(owner.clone(),key.clone())].id;
let (_,rotation)=v.simulation.pose(id).ok_or("Missing crash vehicle")?;
let forward=Quat::from_array(rotation)*Vec3::Z;
let heading=forward.x.atan2(forward.z);
let (sin,cos)=heading.sin_cos();
// The native reset initializes a full upright body. Start clear of the seat,
// then its ordinary ragdoll/contact solver takes over immediately.
let p=Vec3::from_array(ejection.position)+Vec3::Y*0.25;
let matrix=[[cos,0.,-sin,0.],[0.,1.,0.,0.],[sin,0.,cos,0.],[p.x,p.y,p.z,0.]];
{
let mut skater=world.resource_mut::<crate::physics::SkaterRuntime>();
skater.player_input.request_teleport(matrix).map_err(|e|e.to_string())?;
skater.teleport_state.request_vehicle_ejection(matrix,ejection.velocity,ejection.angular_velocity);
}
v.simulation.set_occupied(id,false);
v.simulation.vehicles.get_mut(&id).unwrap().controls=Controls{brake:0.2,..Default::default()};
v.driver=None;v.pose=None;v.crash_handoff=true;
for (entity,visibility) in v.hidden.drain(..) {
if let Some(mut current)=world.get_mut::<Visibility>(entity) {*current=visibility;}
}
v.events.push(json!({"name":"vehicle_bailed","owner":owner,"key":key,
"reason":ejection.reason,"position":ejection.position,"velocity":ejection.velocity,
"angular_velocity":ejection.angular_velocity}));
Ok(())
}
pub(super) fn retire(world: &mut World, owner: &str) {
world.resource_scope(|world, mut v: Mut<Vehicles>| {
if v.driver.as_ref().is_some_and(|d| d.owner == owner) {
let _ = exit_now(world, &mut v, true);
}
let keys: Vec<_> = v
.owned
.keys()
.filter(|(o, _)| o == owner)
.cloned()
.collect();
for key in keys {
if let Some(i) = v.owned.remove(&key) {
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);
}
}
});
}
pub(super) fn clear(world: &mut World) {
// A map transition has already installed a new skater: never teleport it back to the old map.
world.resource_scope(|world, mut v: Mut<Vehicles>| {
for (_, i) in std::mem::take(&mut v.owned) {
world.despawn(i.entity);
}
for (id, visibility) in v.hidden.drain(..) {
if let Some(mut current) = world.get_mut::<Visibility>(id) {
*current = visibility;
}
}
v.driver = None;
v.pose = None;
v.last_visual.clear();v.blend_from.clear();v.visual_phase.clear();v.steering_visual=0.;
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();
});
}
fn ensure_ground(world: &World, v: &mut Vehicles) -> Result<(), String> {
if v.simulation.world.colliders.is_empty() {
v.simulation.ground(
world
.resource::<crate::physics::GamePhysics>()
.world_triangles()
.iter()
.map(|t| t.triangle.vertices.map(|p| [p.x, p.y, p.z])),
)?;
}
Ok(())
}
pub(super) fn command(
world: &mut World,
root: &std::path::Path,
owner: &str,
command: Command,
) -> Result<(), String> {
world.resource_scope(|world, mut v: Mut<Vehicles>| {
let key = match &command {
Command::VehicleTune { key, .. }
| Command::VehicleSpawn { key, .. }
| Command::VehicleRemove { key }
| Command::VehicleEnter { key }
| Command::VehicleExit { key }
| Command::VehicleReset { key, .. }
| Command::VehicleControl { key, .. } => key.clone(),
_ => unreachable!(),
};
let owned_key = (owner.to_owned(), key.clone());
match command {
Command::VehicleTune { tuning, .. } => {
let id = v.owned.get(&owned_key).ok_or("Unknown vehicle")?.id;
let car = v.simulation.vehicles.get_mut(&id).unwrap();
car.definition = tuning.apply(&car.definition)?;
let grip = car.definition.tire_grip;
for wheel in car.controller.wheels_mut() {
wheel.friction_slip = grip;
}
}
Command::VehicleSpawn {
definition,
position,
heading,
..
} => {
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.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)?;
let def: VehicleDefinition =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
def.validate()?;
let bytes = skate_mods::read_bounded(root, &def.model, 32 * 1024 * 1024)?;
validate_glb(&bytes)?;
let clips = animations::Clips::load(
root,
&def,
&world
.resource::<crate::physics::SkaterRuntime>()
.animation
.evaluator
.frames
.bone_names,
)?;
let model = root
.join(&def.model)
.canonicalize()
.map_err(|e| e.to_string())?;
let package_root = super::package_root()
.canonicalize()
.map_err(|e| e.to_string())?;
let relative = model
.strip_prefix(package_root)
.map_err(|_| "Model outside mod packages")?
.to_string_lossy()
.replace('\\', "/");
let scene = world
.resource::<AssetServer>()
.load(GltfAssetLabel::Scene(0).from_asset(format!("mods://{relative}")));
ensure_ground(world, &mut v)?;
let id = v.simulation.spawn(def.clone(), position, heading)?;
let entity = world
.spawn((
Transform::from_translation(Vec3::from_array(position))
.with_rotation(Quat::from_rotation_y(heading)),
Visibility::default(),
))
.id();
world.spawn((
SceneRoot(scene.clone()),
Transform::from_translation(Vec3::from_array(def.model_offset))
.with_rotation(Quat::from_rotation_y(def.model_yaw))
.with_scale(Vec3::splat(def.model_scale)),
ChildOf(entity),
));
let clock = v.clock;
v.owned.insert(
owned_key,
Instance {
id,
entity,
scene,
clips,
last_control: clock,
definition_path: definition,
},
);
event(&mut v, owner, &key, "vehicle_spawned");
}
Command::VehicleRemove { .. } => {
if v.driver
.as_ref()
.is_some_and(|d| d.owner == owner && d.key == key)
{
exit_now(world, &mut v, true)?;
}
if let Some(i) = v.owned.remove(&owned_key) {
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");
}
}
Command::VehicleEnter { .. } => {
if v.driver.is_some()
|| world.resource::<crate::replay::Replay>().active
|| world
.resource::<crate::map_transition::MapTransition>()
.busy()
{
return Ok(());
}
let i = v.owned.get(&owned_key).ok_or("Unknown vehicle")?;
if !world
.resource::<AssetServer>()
.is_loaded_with_dependencies(i.scene.id())
{
return Ok(());
}
if !world.resource::<crate::animation::AnimationStatus>().ready {
return Ok(());
}
let skater = world.resource::<crate::physics::SkaterRuntime>();
if skater.player_input.pending_teleport().is_some()
|| world
.resource::<crate::physics::GamePhysics>()
.board_wiping_out
{
return Ok(());
}
let m = skater.animated_skeleton.roots.animation_to_world;
let p = [m[3][0], m[3][1], m[3][2]];
let car = v.simulation.pose(i.id).ok_or("Missing vehicle body")?.0;
if Vec3::from_array(p).distance(Vec3::from_array(car)) > 4.
|| v.simulation.vehicles[&i.id]
.controller
.current_vehicle_speed
.abs()
> 3.
{
return Ok(());
}
v.driver = Some(Driver {
owner: owner.into(),
key: key.clone(),
phase: "entering",
time: 0.,
return_position: p,
return_heading: m[2][0].atan2(m[2][2]),
});
event(&mut v, owner, &key, "vehicle_entering");
}
Command::VehicleExit { .. } => {
if let Some(d) = &v.driver {
if d.owner != owner || d.key != key {
return Ok(());
}
if d.phase != "driving" { return Ok(()); }
}
if let Some(i) = v.owned.get(&owned_key) {
if v.simulation.vehicles[&i.id]
.controller
.current_vehicle_speed
.abs()
> 3.
{
event(&mut v, owner, &key, "vehicle_exit_blocked");
return Ok(());
}
}
if let Some(d) = &mut v.driver {
d.phase = "exiting";
d.time = 0.;
}
}
Command::VehicleControl { controls, .. } => {
let clock = v.clock;
let i = v.owned.get_mut(&owned_key).ok_or("Unknown vehicle")?;
i.last_control = clock;
let id = i.id;
v.simulation.vehicles.get_mut(&id).unwrap().controls = controls;
}
Command::VehicleReset {
position, heading, ..
} => {
let i = v.owned.get(&owned_key).ok_or("Unknown vehicle")?;
let id = i.id;
v.simulation.reset(id, position, heading)?;
v.previous_motion.remove(&id);
v.rendered_motion.remove(&id);
event(&mut v, owner, &key, "vehicle_reset");
}
_ => unreachable!(),
}
Ok(())
})
}
fn validate_glb(bytes: &[u8]) -> Result<(), String> {
if bytes.len() < 20
|| &bytes[..4] != b"glTF"
|| u32::from_le_bytes(bytes[4..8].try_into().unwrap()) != 2
{
return Err("Vehicle model must be GLB 2".into());
}
let n = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
let json: Value = serde_json::from_slice(bytes.get(20..20 + n).ok_or("Truncated GLB")?)
.map_err(|e| e.to_string())?;
for field in ["buffers", "images"] {
for entry in json[field].as_array().into_iter().flatten() {
if entry.get("uri").is_some() {
return Err("Vehicle GLB must embed all buffers and textures".into());
}
}
}
Ok(())
}
fn tick(world: &mut World) {
if world.resource::<crate::replay::Replay>().active {
return;
}
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
.values()
.filter(|i| clock - i.last_control > 0.25)
.map(|i| i.id)
.collect();
for id in parked {
v.simulation.vehicles.get_mut(&id).unwrap().controls = Controls {
brake: 0.2,
..Default::default()
};
}
let driver_info = v
.driver
.as_ref()
.map(|d| (d.owner.clone(), d.key.clone(), d.phase));
if let Some((owner, key, phase)) = &driver_info {
if *phase != "driving" {
if let Some(i) = v.owned.get(&(owner.clone(), key.clone())) {
let id = i.id;
v.simulation.vehicles.get_mut(&id).unwrap().controls = Controls {
brake: 1.,
..Default::default()
};
}
}
}
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 { 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|
network::motion(&v, id).map(|m| (id, m))).collect();
v.simulation.step(dt);
}
if let Some(id)=occupied_id {
if let Some(ejection)=v.simulation.take_ejection(id) {
if let Err(error)=eject_now(world,&mut v,ejection) {warn!("Vehicle ejection: {error}");}
return;
}
}
if let Some((owner, key, phase)) = driver_info {
if let Some(i) = v.owned.get(&(owner.clone(), key.clone())) {
let def = &v.simulation.vehicles[&i.id].definition;
let duration = i.clips.duration(if phase == "entering" {
def.animations.enter.as_ref()
} else {
def.animations.exit.as_ref()
});
if let Some(d) = &mut v.driver {
d.time += dt;
}
if phase != "driving" && v.driver.as_ref().unwrap().time >= duration {
if phase == "exiting" {
if let Err(e) = exit_now(world, &mut v, false) {
warn!("Vehicle exit: {e}");
}
} else {
let d = v.driver.as_mut().unwrap();
d.phase = "driving";
d.time = 0.;
event(&mut v, &owner, &key, "vehicle_entered");
}
}
}
}
});
}
pub(crate) fn present(world: &mut World) {
let dt=world.resource::<Time<Virtual>>().delta_secs();
let alpha=world.resource::<Time<Fixed>>().overstep_fraction();
let phase=world.resource::<Vehicles>().driver.as_ref().map_or("vanilla",|d|d.phase).to_owned();
let current=crate::animation::capture_vehicle_visual(world);
{
let mut v=world.resource_mut::<Vehicles>();
if phase!=v.visual_phase {
v.blend_from=if v.last_visual.is_empty() {current} else {v.last_visual.clone()};
v.visual_phase=phase;v.blend_time=0.;
}
v.blend_time+=dt;
}
world.resource_scope(|world, mut v: Mut<Vehicles>| {
let failures: Vec<_> = v
.owned
.iter()
.filter_map(|((owner, key), i)| {
if let Some(bevy::asset::LoadState::Failed(error)) =
world.resource::<AssetServer>().get_load_state(i.scene.id())
{
Some((
owner.clone(),
format!("Vehicle {key} model failed: {error}"),
))
} else {
None
}
})
.collect();
for (owner, error) in failures {
world
.resource_mut::<super::Mods>()
.manager
.fail(&owner, error);
}
// Chassis, wheels, rider and camera share one fixed-step render sample.
v.rendered_motion = v.simulation.vehicles.keys().filter_map(|&id| {
let current = network::motion(&v, id)?;
let sample = v.previous_motion.get(&id).map_or_else(|| current.clone(),
|previous| previous.sample(&current, alpha));
Some((id, sample))
}).collect();
for i in v.owned.values() {
if let Some(sample) = v.rendered_motion.get(&i.id) {
if let Some(mut t) = world.get_mut::<Transform>(i.entity) {
*t = sample.body;
}
}
}
let mut wheels = Vec::new();
for instance in v.owned.values() {
for (index, wheel) in v.simulation.vehicles[&instance.id]
.definition
.wheels
.iter()
.enumerate()
{
let Some(name) = &wheel.node else {
continue;
};
let matches: Vec<_> = world
.query_filtered::<(Entity, &Name, &Transform), Without<WheelVisual>>()
.iter(world)
.filter(|(_, n, _)| n.as_str() == name)
.map(|(e, _, t)| (e, *t))
.collect();
for (entity, rest) in matches {
let mut ancestor = entity;
let mut owned = false;
for _ in 0..128 {
let Some(parent) = world.get::<ChildOf>(ancestor) else {
break;
};
ancestor = parent.parent();
if ancestor == instance.entity {
owned = true;
break;
}
}
if owned {
wheels.push((
entity,
WheelVisual {
vehicle: instance.id,
index,
rest,
},
));
}
}
}
}
for (entity, wheel) in wheels {
world.entity_mut(entity).insert(wheel);
}
for (wheel, mut transform) in world
.query::<(&WheelVisual, &mut Transform)>()
.iter_mut(world)
{
if let Some(sample) = v.rendered_motion.get(&wheel.vehicle) {
if let Some(offset) = sample.wheels.get(wheel.index) {
*transform = wheel.rest;
transform.translation += offset.translation;
transform.rotation = wheel.rest.rotation * offset.rotation;
}
}
}
v.pose = None;
let Some(d) = &v.driver else {
return;
};
let Some(i) = v.owned.get(&(d.owner.clone(), d.key.clone())) else {
return;
};
let car = &v.simulation.vehicles[&i.id];
let a = &car.definition.animations;
let name = match d.phase {
"entering" => a.enter.as_ref(),
"exiting" => a.exit.as_ref(),
_ => {
if car.controls.brake > 0.2 {
a.brake.as_ref()
} else if car.controller.current_vehicle_speed < -0.5 {
a.reverse.as_ref()
} else if car.controller.current_vehicle_speed.abs() > 0.5 {
a.drive.as_ref()
} else {
a.idle.as_ref()
}
}
}
.or(a.drive.as_ref());
let target_steering=car.controls.steering;
let steering=v.steering_visual+(target_steering-v.steering_visual)*(1.-(-12.*dt).exp());
let pose = i.clips.pose(name, d.time, d.phase == "driving");
let turn=if d.phase=="driving" {i.clips.pose(if steering>=0. {a.steer_left.as_ref()} else {a.steer_right.as_ref()},d.time,true)} else {None};
let body = v.rendered_motion[&i.id].body;
let q = body.rotation;
let seat = body.translation + q * Vec3::from_array(car.definition.seat);
v.steering_visual=steering;
let roots: Vec<_> = world
.query_filtered::<Entity, With<crate::world::PlayerRoot>>()
.iter(world)
.collect();
for entity in roots {
if !v.hidden.iter().any(|(id, _)| *id == entity) {
if let Some(vis) = world.get::<Visibility>(entity) {
v.hidden.push((entity, *vis));
}
}
if let Some(mut vis) = world.get_mut::<Visibility>(entity) {
*vis = if pose.is_some() {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = Transform::from_translation(seat).with_rotation(q);
}
}
if let Some(pose) = &pose {
crate::animation::vehicle_pose(world, pose, turn.as_deref().map(|p|(p,steering.abs())));
}
v.pose = pose;
});
world.resource_scope(|world,mut v:Mut<Vehicles>| {
let duration=if v.visual_phase=="vanilla" {if v.crash_handoff {0.12} else {0.5}} else {0.4};
if v.blend_time<duration {
let t=(v.blend_time/duration).clamp(0.,1.);
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};
});
}
pub(super) fn input(world: &World) -> Value {
let keys = world.resource::<ButtonInput<KeyCode>>();
let pad = world
.resource::<crate::input::ControllerInput>()
.raw_input();
let pressed = |key| if keys.pressed(key) { 1. } else { 0. };
json!({"throttle":(pressed(KeyCode::KeyW)-pressed(KeyCode::KeyS)+pad.triggers[1]-pad.triggers[0]).clamp(-1.,1.),"steering":(pressed(KeyCode::KeyA)-pressed(KeyCode::KeyD)-if pad.left[0].abs()>0.15 {pad.left[0]} else {0.}).clamp(-1.,1.),"brake":if pad.buttons & 0x1000 != 0 {1.} else {pressed(KeyCode::Space)},"handbrake":keys.pressed(KeyCode::ShiftLeft) || pad.buttons & 0x2000 != 0,"interact":keys.pressed(KeyCode::KeyE) || pad.buttons & 0x8000 != 0,"pad_buttons":pad.buttons})
}
@@ -0,0 +1,351 @@
//! Owner-simulated vehicles, matching the native player's multiplayer ownership.
use super::*;
use serde::{Deserialize, Serialize};
use skate_vehicles::rapier3d::prelude::{RigidBodyType, Rotation, Vector};
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Car {
pub definition: String,
pub position: [f32; 3],
pub rotation: [f32; 4],
pub velocity: [f32; 3],
pub angular: [f32; 3],
pub controls: Controls,
pub occupied: bool,
pub rider: Option<([f32; 3], [f32; 4])>,
pub wheels: Vec<[f32; 3]>,
}
pub(super) struct Target {
state: Car,
age: f32,
}
pub(super) fn simulation_active(
menu: Option<Res<crate::graphics_menu::Menu>>,
net: Option<Res<crate::multiplayer::Multiplayer>>,
) -> bool {
menu.is_none_or(|m| !m.open) || net.is_some_and(|n| n.active())
}
pub(crate) fn capture(world: &World) -> Vec<(String, String, Car)> {
let v = world.resource::<Vehicles>();
v.owned
.iter()
.filter(|(_, i)| !v.remote.contains_key(&i.id))
.filter_map(|((owner, key), i)| {
let car = v.simulation.vehicles.get(&i.id)?;
let body = &v.simulation.world.bodies[car.body];
Some((
owner.clone(),
key.clone(),
Car {
definition: i.definition_path.clone(),
position: body.translation().to_array(),
rotation: body.rotation().to_array(),
velocity: body.linvel().to_array(),
angular: body.angvel().to_array(),
controls: car.controls,
rider: v
.network_pose
.as_ref()
.filter(|_| {
v.driver
.as_ref()
.is_some_and(|d| d.owner == *owner && d.key == *key)
})
.map(|pose| {
let body = Mat4::from_rotation_translation(
Quat::from_array(body.rotation().to_array()),
Vec3::from_array(body.translation().to_array()),
);
let t = Transform::from_matrix(
body.inverse() * crate::physics::network::matrix(pose.root),
);
(t.translation.to_array(), t.rotation.to_array())
}),
occupied: v
.driver
.as_ref()
.is_some_and(|d| d.owner == *owner && d.key == *key),
wheels: car
.controller
.wheels()
.iter()
.map(|w| [w.steering, w.rotation, w.raycast_info().suspension_length])
.collect(),
},
))
})
.collect()
}
pub(crate) fn receive(
world: &mut World,
root: &std::path::Path,
owner: &str,
key: &str,
state: Car,
) -> Result<(), String> {
if let Some((p, q)) = state.rider {
let q = Quat::from_array(q);
if p.iter().any(|x| !x.is_finite() || x.abs() > 20.)
|| !q.is_finite()
|| (q.length() - 1.).abs() > 0.02
{
return Err("Invalid rider pose".into());
}
}
let spawn = Command::VehicleSpawn {
key: key.into(),
definition: state.definition.clone(),
position: state.position,
heading: 0.,
};
let q = Quat::from_array(state.rotation);
if !spawn.validate()
|| !state.controls.valid()
|| !q.is_finite()
|| (q.length() - 1.).abs() > 0.02
|| state
.velocity
.iter()
.chain(&state.angular)
.any(|x| !x.is_finite() || x.abs() > 200.)
|| state.wheels.len() > 16
|| state
.wheels
.iter()
.flatten()
.any(|x| !x.is_finite() || x.abs() > 100000.)
{
return Err("Invalid remote vehicle".into());
}
let owned = (owner.into(), key.into());
let replace = world
.resource::<Vehicles>()
.owned
.get(&owned)
.is_some_and(|i| i.definition_path != state.definition);
if replace {
remove(world, owner, key);
}
if !world.resource::<Vehicles>().owned.contains_key(&owned) {
super::command(world, root, owner, spawn)?;
}
let mut v = world.resource_mut::<Vehicles>();
let id = v.owned[&owned].id;
let car = v.simulation.vehicles.get_mut(&id).unwrap();
car.remote = true;
car.controls = state.controls;
let handle = car.body;
let body = &mut v.simulation.world.bodies[handle];
body.set_body_type(RigidBodyType::KinematicVelocityBased, true);
if !v.remote.contains_key(&id) {
let body = &mut v.simulation.world.bodies[handle];
body.set_translation(Vector::from_array(state.position), true);
body.set_rotation(
Rotation::from_xyzw(
state.rotation[0],
state.rotation[1],
state.rotation[2],
state.rotation[3],
),
true,
);
}
v.simulation.set_occupied(id, state.occupied);
v.remote.insert(id, Target { state, age: 0. });
Ok(())
}
pub(crate) fn remove(world: &mut World, owner: &str, key: &str) {
// Removal has no asset access or effects on the locally occupied vehicle.
let _ = super::command(
world,
std::path::Path::new(""),
owner,
Command::VehicleRemove { key: key.into() },
);
}
pub(super) fn advance(v: &mut Vehicles, dt: f32) {
for (&id, target) in &mut v.remote {
target.age += dt;
let Some(car) = v.simulation.vehicles.get_mut(&id) else {
continue;
};
let body = &mut v.simulation.world.bodies[car.body];
let source = Vec3::from_array(target.state.position)
+ Vec3::from_array(target.state.velocity) * target.age.min(0.15);
let mut error = source - Vec3::from_array(body.translation().to_array());
if error.length() > 5. {
body.set_translation(Vector::from_array(source.to_array()), true);
error = Vec3::ZERO;
}
let velocity = if target.age < 0.15 {
Vec3::from_array(target.state.velocity)
} else {
Vec3::ZERO
};
body.set_linvel(
Vector::from_array(
(velocity + (error * 10.).clamp_length_max(5.))
.clamp_length_max(100.)
.to_array(),
),
true,
);
let current = Quat::from_array(body.rotation().to_array());
let desired = Quat::from_array(target.state.rotation);
let delta = (desired * current.inverse()).normalize();
let delta = if delta.w < 0. { -delta } else { delta };
let angular = if target.age < 0.15 {
Vec3::from_array(target.state.angular)
} else {
Vec3::ZERO
};
body.set_angvel(
Vector::from_array(
(angular + delta.to_scaled_axis() * 10.)
.clamp_length_max(20.)
.to_array(),
),
true,
);
car.controls = target.state.controls;
car.controller.current_vehicle_speed = velocity.dot(current * Vec3::Z);
for (wheel, values) in car
.controller
.wheels_mut()
.iter_mut()
.zip(&target.state.wheels)
{
wheel.steering = values[0];
wheel.rotation = values[1];
}
}
}
pub(crate) fn attached_root(v: &Vehicles, peer: u64) -> Option<Transform> {
let prefix = format!("@{peer}:");
v.owned
.iter()
.filter(|((owner, _), _)| owner.starts_with(&prefix))
.find_map(|(_, i)| {
let target = v.remote.get(&i.id)?;
if !target.state.occupied {
return None;
}
let (p, q) = target.state.rider?;
let body = v.rendered_motion.get(&i.id)?.body;
Some(Transform::from_matrix(
body.to_matrix()
* Mat4::from_rotation_translation(Quat::from_array(q), Vec3::from_array(p)),
))
})
}
pub(crate) struct CollisionShape {
pub position: [f32; 3],
pub rotation: [f32; 4],
pub velocity: [f32; 3],
pub angular: [f32; 3],
pub half: [f32; 3],
pub offset: [f32; 3],
pub rounding: f32,
pub mass: f32,
}
pub(crate) fn collision_shapes(v: &Vehicles) -> Vec<CollisionShape> {
v.simulation
.vehicles
.values()
.map(|car| {
let b = &v.simulation.world.bodies[car.body];
let d = &car.definition;
CollisionShape {
position: b.translation().to_array(),
rotation: b.rotation().to_array(),
velocity: b.linvel().to_array(),
angular: b.angvel().to_array(),
half: d.half_extents,
offset: d.collider_offset,
rounding: d.collider_rounding,
mass: d.mass,
}
})
.collect()
}
pub(super) fn skater_proxies(world: &World, v: &mut Vehicles) {
use skate_vehicles::rapier3d::prelude::{ColliderBuilder, RigidBodyBuilder};
let mut frames = world
.resource::<crate::multiplayer::Multiplayer>()
.collision_players();
if !v.occupied() {
frames.push((
0,
crate::physics::network::capture_body(
world.resource::<crate::physics::GamePhysics>(),
world.resource::<crate::physics::SkaterRuntime>(),
),
));
}
let mut live = std::collections::BTreeSet::new();
for (peer, frame) in frames {
for (index, body) in frame.bodies.iter().enumerate() {
if frame.enabled & (1u64 << index) == 0 {
continue;
}
// Only near a car: bounded spheres approximate the native articulated collision parts.
if !v.simulation.vehicles.values().any(|c| {
Vec3::from_array(v.simulation.world.bodies[c.body].translation().to_array())
.distance_squared(Vec3::from_array(body.pose.p))
< 100.
}) {
continue;
}
let key = (peer, index);
live.insert(key);
let handle = *v.skaters.entry(key).or_insert_with(|| {
v.simulation
.world
.insert(
RigidBodyBuilder::kinematic_velocity_based(),
ColliderBuilder::ball(if index < 7 { 0.08 } else { 0.14 }).friction(0.3),
)
.0
});
let proxy = &mut v.simulation.world.bodies[handle];
proxy.set_translation(Vector::from_array(body.pose.p), true);
proxy.set_linvel(Vector::from_array(body.velocity), true);
}
}
let removed: Vec<_> = v
.skaters
.keys()
.filter(|key| !live.contains(key))
.copied()
.collect();
for key in removed {
if let Some(body) = v.skaters.remove(&key) {
v.simulation.world.remove_body(body);
}
}
}
pub(super) fn occupied(v: &Vehicles, id: u64) -> bool {
v.remote.get(&id).map_or_else(
|| {
v.driver
.as_ref()
.and_then(|d| v.owned.get(&(d.owner.clone(), d.key.clone())))
.is_some_and(|i| i.id == id)
},
|t| t.state.occupied,
)
}
pub(super) fn motion(v: &Vehicles, id: u64) -> Option<interpolation::Motion> {
let mut m = interpolation::Motion::capture(&v.simulation, id)?;
if let Some(target) = v.remote.get(&id) {
let d = &v.simulation.vehicles[&id].definition;
for (wheel, values) in m.wheels.iter_mut().zip(&target.state.wheels) {
wheel.translation.y = (d.suspension_length - values[2]) / d.model_scale;
}
}
Some(m)
}
+30 -7
View File
@@ -93,6 +93,20 @@ impl Multiplayer {
let rtt = self.lobby.as_ref().map(|lobby| lobby.stats.rtt_ms);
format!("provider:{provider} active:{} remote_count:{} rtt_ms:{rtt:?}", self.active(), self.remotes.len())
}
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()
}
@@ -321,7 +335,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
@@ -575,7 +589,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);
@@ -592,6 +606,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)]
@@ -620,39 +637,45 @@ fn hud(
net: Res<Multiplayer>,
physics: Res<GamePhysics>,
mut label: Single<&mut Text, With<NetworkHud>>,
mods: Res<crate::modding::network::ModSync>,
) {
***label = if net.active() {
format!(
"{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer",
"{}\n{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer",
net.status,
net.rates,
net.provider_metrics,
net.visual_status,
mods.status,
physics.network_contacts
)
} else {
String::new()
};
}
fn send(mut net: ResMut<Multiplayer>, physics: Res<GamePhysics>, skater: Res<SkaterRuntime>) {
fn send(mut net: ResMut<Multiplayer>, physics: Res<GamePhysics>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
if !net.active() || skater.pose_generation == 0 {
return;
}
let now = net.started.elapsed().as_millis() as u64;
if net.last_body.elapsed() >= Duration::from_millis(49) {
if let Some(p) = Packed::body(&network::capture_body(&physics, &skater)) {
let mut state=network::capture_body(&physics,&skater);
if let Some(pose)=&vehicles.network_pose {state.root=pose.root;}
if vehicles.occupied() {state.enabled=1u64<<62;}
if let Some(p) = Packed::body(&state) {
net.lobby.as_mut().unwrap().publish(packed::BODY, p, now);
}
net.last_body = Instant::now();
}
}
fn send_pose(mut net: ResMut<Multiplayer>, skater: Res<SkaterRuntime>) {
fn send_pose(mut net: ResMut<Multiplayer>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
if !net.active() || skater.pose_generation == 0 {
return;
}
let now = net.started.elapsed().as_millis() as u64;
if net.last_pose.elapsed() >= Duration::from_millis(if net.loopback { 49 } else { 99 }) {
if let Some(p) = Packed::pose(&network::capture_pose(&skater, &net.anchors)) {
let pose=vehicles.network_pose.clone().unwrap_or_else(||network::capture_pose(&skater,&net.anchors));
if let Some(p) = Packed::pose(&pose) {
net.lobby.as_mut().unwrap().publish(packed::POSE, p, now);
}
net.last_pose = Instant::now();
+10 -1
View File
@@ -7,6 +7,7 @@ struct RemoteSkins {
actors: BTreeMap<u64, RemoteSkin>,
rest: Vec<Mat4>,
parents: Vec<i32>,
board: Option<usize>,
}
#[derive(Default)]
struct RemoteSkin {
@@ -58,7 +59,9 @@ impl Plugin for RemoteRenderPlugin {
.iter()
.map(|&p| p as i32)
.collect();
let board=skater.animation.evaluator.frames.bone_names.iter().position(|n|n=="SKATEBOARD_ROOT");
app.insert_resource(RemoteSkins {
board,
reported: 0.,
actors: BTreeMap::new(),
rest,
@@ -66,7 +69,7 @@ impl Plugin for RemoteRenderPlugin {
})
.add_systems(
Update,
(spawn, bind, present).chain().after(FrameSet::Animation),
(spawn, bind, present).chain().after(crate::modding::vehicles::present),
);
}
}
@@ -188,6 +191,7 @@ fn globals(bones: &[skate_net::Bone], skin: &RemoteSkins) -> Vec<Mat4> {
}
fn present(
mut net: ResMut<Multiplayer>,
vehicles: Res<crate::modding::vehicles::Vehicles>,
mut skins: ResMut<RemoteSkins>,
mut nodes: Query<&mut Transform>,
) {
@@ -272,6 +276,11 @@ fn present(
}
}
}
if let Some(root)=skin.root {
if let Some(attached)=crate::modding::vehicles::network::attached_root(&vehicles,id) {if let Ok(mut t)=nodes.get_mut(root) {*t=attached;}}
}
let seated=remote.body.enabled & (1u64<<62)!=0;
for &(entity,i,_) in &skin.bindings {if skins.board==Some(i) {if let Ok(mut t)=nodes.get_mut(entity) {t.scale=Vec3::splat(if seated {0.001} else {1.});}}}
skins.actors.insert(id, skin);
}
if now < skins.reported || now - skins.reported >= 1. {
+17 -2
View File
@@ -115,6 +115,7 @@ pub(crate) struct GamePhysics {
pub processed_flags_2468: u32,
/// Toolkit ctor82C0680C clears8384bit7; wipeout entry/exit owns changes.
pub board_wiping_out: bool,
pub trainer: skate_mods::TrainerTuning,
}
/// Cross-phase records for the current fixed tick. Subsystems retain their
@@ -230,7 +231,6 @@ impl GamePhysics {
pub(crate) fn difficulty_index(&self) -> u32 { self.animation_profile.physics_mode }
#[cfg(test)]
pub(crate) fn world_triangles(&self) -> &[skate_core::physics::board_world::WorldTriangle] { self.world.triangles() }
pub(crate) fn world(&self) -> &BoardWorld {
@@ -344,6 +344,7 @@ impl GamePhysics {
exchange: SimulationExchange::new(0),
processed_flags_2468,
board_wiping_out: false,
trainer: Default::default(),
})
}
@@ -408,6 +409,7 @@ impl Plugin for PhysicsPlugin {
}
fn advance(
vehicles: Res<crate::modding::vehicles::Vehicles>,
mut physics: ResMut<GamePhysics>,
mut skater: ResMut<SkaterRuntime>,
mut controls: ResMut<PlayerControls>,
@@ -418,7 +420,7 @@ fn advance(
mut exit: MessageWriter<AppExit>,
mut performance: Option<ResMut<crate::performance::Performance>>,
) {
if physics.failed {
if physics.failed || vehicles.occupied() {
return;
}
let timer = performance.as_ref().map(|_| std::time::Instant::now());
@@ -596,3 +598,16 @@ mod offboard_jump_playback_tests;
#[cfg(test)]
#[path = "tests/offboard_root_trace.rs"]
mod offboard_root_trace;
impl SkaterRuntime {
/// Observe the current grind publication without adding a second grind owner.
pub(crate) fn mod_grind(&self) -> (bool, &str, u32, f32) {
let out = &self.player_input.physical.grinds;
let active = self.grind.active_family().is_some();
let name = if active {
grind_chromosome::names::lookup(out.animation_chromosome_268)
.map(|name| name.attribute).unwrap_or("")
} else { "" };
(active, name, out.words_136_140[0], self.grind.last_grind_distance())
}
}
@@ -251,6 +251,7 @@ pub(crate) fn advance(
surface_up_y: physics.riding.ground.wheel_normal.y,
},
);
skater.animation.motion.hold_fakie = physics.trainer.hold_fakie;
let observations = AnimationPhysical {
conditions,
feedback,
+6 -1
View File
@@ -80,6 +80,10 @@ pub(super) fn advance(
input_available,
)?;
skater.ground_settings = skater.ground_profiles.select(skater.player_input.processed.state_variant_index_2528, skater.player_input.processed.surface_mode_2540)?;
if physics.trainer != skate_mods::TrainerTuning::default() {
skater.ground_settings = std::sync::Arc::new(skater.ground_settings.tuned(physics.trainer));
}
let mut vehicle_ejected = false;
if teleported {
skater.respawn.reset_measurements();
#[cfg(test)]
@@ -98,6 +102,7 @@ pub(super) fn advance(
&mut skater.skeleton_collision,
&mut skater.collision_feedback,
);
vehicle_ejected = player_state::apply_vehicle_ejection(physics, skater)?;
}
//World8275EC0C ends board queries before PostInput/state selection.
physics.riding.finish_wheel_queries()?;
@@ -110,7 +115,7 @@ pub(super) fn advance(
skater.offboard_grab.execute_queries(&scene)?;
}
let state_before_selection = skater.player_state.current();
player_state::post_input_and_select(physics, skater)?;
if !vehicle_ejected { player_state::post_input_and_select(physics, skater)?; }
let state_after_selection = skater.player_state.current();
super::offboard_audit_trace::stage(tick, "selected", physics, skater, controls);
#[cfg(test)]
@@ -43,6 +43,7 @@ pub(super) fn execute(
p.state_variant_index_2528,
skater.animation_input.extra.jump_strength,
)?;
let vertical_jump = vertical_jump * physics.trainer.grind_pop;
let geometry_side_jump = settings::side(family, p.grind.geometry_kind_1464);
let input = LaunchInput {
velocity_400: p.vectors_400_416[0].map(f32::from_bits),
@@ -25,7 +25,7 @@ pub(crate) fn execute(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
Family::FiftyFifty,
p.state_variant_index_2528,
skater.animation_input.extra.jump_strength,
)?,
)? * physics.trainer.grind_pop,
};
let velocity = super::super::launch::apply(
&mut physics.board,
@@ -33,7 +33,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
p.flags_2472,
);
physics.settings.wheel_material = s.wheel_material;
let mode = *skater
let mut mode = *skater
.ground_animation_settings
.modes
.get(p.state_variant_index_2528 as usize)
@@ -43,6 +43,9 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
p.state_variant_index_2528
)
})?;
mode.minimum_height_64 *= physics.trainer.pop;
mode.minimum_height_68 *= physics.trainer.pop;
mode.maximum_height *= physics.trainer.pop;
skater.ground_animation.jump = ground_jump::calculate(
GroundJumpInput {
flags_2468: p.flags_2468,
@@ -118,7 +118,7 @@ impl GroundSettings {
propulsion: GroundPropulsionInput {
flags_2468: p.flags_2468,
flags_2472: p.flags_2472,
target_speed: a.contacts.push_speed,
target_speed: a.contacts.push_speed * self.push_target_multiplier,
signed_speed: p.scalar_2612,
absolute_body_speed: t.absolute_speed,
scalar_2660: t.total_mass,
@@ -40,6 +40,7 @@ impl GroundProfiles {
}
}
#[derive(Clone)]
pub(crate) struct GroundSettings {
steering: SteeringSettings,
wobble: SpeedWobbleSettings,
@@ -58,6 +59,7 @@ pub(crate) struct GroundSettings {
contact_force_time: f32,
pub wheel_material: RetailContactMaterial,
pub foot_force_offset: f32,
pub push_target_multiplier: f32,
pub absorption_front: f32,
pub absorption_rear: f32,
pub surface_braking_factor: f32,
@@ -75,6 +77,7 @@ impl GroundSettings {
//broadcasts82165A10 (zero) into830BD380 for speed override direction.
let threshold = [f32::from_bits(0x3586_37bd); 4];
Ok(Self {
push_target_multiplier: 1.,
foot_force_offset: f("physics_feet", "FootForceOffset")?,
absorption_front: f("physics_feet", "AbsorptionFootForceScalar")?,
absorption_rear: f("physics_feet", "AbsorptionFootForceRearScalar")?,
@@ -204,6 +207,22 @@ impl GroundSettings {
wobble_amplitude: m("Hash_5B57F2CCCCEEF430")?,
})
}
pub fn tuned(&self, tuning: skate_mods::TrainerTuning) -> Self {
let mut result=self.clone();
result.push_target_multiplier = tuning.push_speed;
result.propulsion.maximum_pushable_speed *= tuning.push_speed;
for dv in &mut result.propulsion.mode_speed_changes { *dv *= tuning.push_power; }
result.propulsion.braking.input_force *= tuning.braking;
result.propulsion.braking.override_force *= tuning.braking;
result.steering.general_scalar *= tuning.steering;
result.wobble_amplitude *= tuning.wobble;
result.slide.friction *= tuning.grip;
result.wheel_material.static_friction *= tuning.grip;
result.wheel_material.dynamic_friction *= tuning.grip;
result.heading.turn_strength *= tuning.turn_power;
result.drag.balance_drag *= tuning.manual_drag;
result
}
pub fn board(&self) -> GroundBoardSettings<'_> {
GroundBoardSettings {
steering: &self.steering,
+23
View File
@@ -336,3 +336,26 @@ impl Proxies {
}
}
}
impl Proxies {
/// Vehicle chassis join the existing native contact solver; reactions remain local.
pub fn append_vehicle(&mut self,shape:&crate::modding::vehicles::network::CollisionShape,physics:&GamePhysics,skater:&SkaterRuntime) {
let p=Vec3::from_array(shape.position);
if !physics.board.bodies().iter().chain(skater.skeleton.bodies()).any(|b|Vec3::from_array(xyz(b.rates.position)).distance_squared(p)<100.) {return;}
let base=skater.skeleton.bodies().len()+skater.skeleton_drives.targets.bodies.len()+self.bodies.len();
let mut body=physics.board.bodies()[0];
let q=Quat::from_array(shape.rotation).normalize();
body.rates.position=vector(shape.position);
body.rates.orientation=RetailQuaternion{x:q.x,y:q.y,z:q.z,w:q.w};
body.rates.basis=skate_core::math::Basis3{columns:Mat3::from_quat(q).to_cols_array_2d()};
body.inertia.inverse_mass=1./shape.mass;
let [x,y,z]=shape.half; body.inertia.inverse_tensor=vector([3./(shape.mass*(y*y+z*z)),3./(shape.mass*(x*x+z*z)),3./(shape.mass*(x*x+y*y))]);
body.rates.world_inverse_inertia=world_inverse_inertia(body.rates.basis,body.inertia.inverse_tensor);
body.rates.linear_velocity=vector(shape.velocity);body.rates.angular_velocity=vector(shape.angular);
body.rates.force_acceleration=Vector3::ZERO;body.rates.torque_acceleration=Vector3::ZERO;body.state_flags=4;
self.bodies.push(body);
self.volumes.push(BoardWorldVolume{body:CollisionBody::Attached(base),primitive:ContactPrimitive::RoundedBox{
center:vector((p+q*Vec3::from_array(shape.offset)).to_array()),basis:body.rates.basis,
half_extents:vector(shape.half.map(|x|x-shape.rounding)),radius:shape.rounding},linear_velocity:body.rates.linear_velocity,material:skate_core::physics::contact::RetailContactMaterial{static_friction:0.4,dynamic_friction:0.3,restitution:0.1}});
}
}
@@ -43,7 +43,7 @@ pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re
.skeleton_controller
.request(4, &mut skater.skeleton_collision)?;
if skater.offboard.air_prediction.is_none() {
let launch = super::air_trajectory::prepare(skater, false)?;
let launch = super::air_trajectory::prepare(skater, false, physics.trainer.offboard_jump)?;
super::air_trajectory::launch(physics, skater, launch)?;
}
let p = &skater.player_input.processed;
@@ -136,7 +136,7 @@ pub(crate) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> R
can_requery,
&mut skater.wipeout.state,
) {
let mut launch = super::air_trajectory::prepare(skater, false)?;
let mut launch = super::air_trajectory::prepare(skater, false, physics.trainer.offboard_jump)?;
launch.velocity = relaunch.velocity;
launch.has_target = true;
let feet = feet_world(skater);
@@ -4,7 +4,7 @@ use ground_query::GroundQueryScene;
use skate_core::player::offboard::{
air_completion, air_launch, air_prediction, air_queries, ground_query,
};
pub(crate) fn prepare(skater: &SkaterRuntime, current: bool) -> Result<air_launch::Launch, String> {
pub(crate) fn prepare(skater: &SkaterRuntime, current: bool, height_multiplier: f32) -> Result<air_launch::Launch, String> {
let p = &skater.player_input.processed;
let toolkit = skater
.player_input
@@ -39,7 +39,7 @@ pub(crate) fn prepare(skater: &SkaterRuntime, current: bool) -> Result<air_launc
&air_launch::JumpInput {
reference_up_144: c.state.frame_output.frame[1],
contact_active_708: c.state.contact.active,
height_792: skater.offboard.jump_height,
height_792: skater.offboard.jump_height * height_multiplier,
velocity_scalar_796: skater.offboard.jump_speed_scalar,
speed_704: c.state.motion.speed_704,
steering_760: c.state.intent.steering,
@@ -177,7 +177,7 @@ impl ground_sync::Services for Services<'_> {
}
}
fn prepare_biped_launch(&mut self) -> Self::Launch {
super::air_trajectory::prepare(self.skater, false)
super::air_trajectory::prepare(self.skater, false, self.physics.trainer.offboard_jump)
}
fn skeleton_point_10960(&self) -> Vector {
self.skater.animated_skeleton.record.centre_of_mass
@@ -164,3 +164,23 @@ pub(crate) fn resume_after_climb(physics: &mut GamePhysics, skater: &mut SkaterR
Ok(())
}
/// Vehicle ownership has ended; reset first, then enter the native ragdoll and seed momentum.
pub(crate) fn apply_vehicle_ejection(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<bool, String> {
let Some((velocity, angular)) = skater.teleport_state.take_vehicle_ejection() else { return Ok(false); };
transition::set(physics, skater, PhysicalStateId::WipeoutGround)?;
let velocity=skate_core::math::Vector3::new(velocity[0],velocity[1],velocity[2]);
let angular=skate_core::math::Vector3::new(angular[0],angular[1],angular[2]);
for body in skater.skeleton.bodies_mut() {
body.rates.linear_velocity=velocity;
body.rates.angular_velocity=angular;
}
for body in physics.board.bodies_mut() {
body.rates.linear_velocity=velocity;
body.rates.angular_velocity=angular;
}
skater.animated_skeleton.motion.velocity_world=[velocity.x,velocity.y,velocity.z,0.];
skater.player_input.processed.vectors_544_560_592_608[3]=[velocity.x,velocity.y,velocity.z,0.].map(f32::to_bits);
skater.wipeout_state.state.velocity=[velocity.x,velocity.y,velocity.z,0.];
Ok(true)
}
@@ -23,14 +23,21 @@ pub(crate) struct Runtime {
checkpoint: Checkpoint,
pending_reply: Option<Target>,
manual_on_board: Option<bool>,
vehicle_ejection: Option<([f32; 3], [f32; 3])>,
}
impl Runtime {
/// Explicit manual return uses the actor-reset publication without replacing
/// the automatic recovery checkpoint owned by this runtime.
pub fn request_manual(&mut self, transform: AnimationPartTransform, on_board: bool) {
self.vehicle_ejection = None;
self.pending_reply = Some(Target { transform: transform.map(|r| r.map(f32::to_bits)), on_board });
self.manual_on_board = Some(on_board);
}
pub fn request_vehicle_ejection(&mut self, transform: AnimationPartTransform, velocity: [f32;3], angular: [f32;3]) {
self.request_manual(transform, false);
self.vehicle_ejection = Some((velocity, angular));
}
pub fn take_vehicle_ejection(&mut self) -> Option<([f32;3],[f32;3])> { self.vehicle_ejection.take() }
pub fn take_manual_on_board(&mut self) -> Option<bool> { self.manual_on_board.take() }
pub(super) fn set_checkpoint(&mut self, checkpoint: Checkpoint) {
self.checkpoint = checkpoint;
@@ -42,6 +49,7 @@ impl Runtime {
checkpoint,
pending_reply: None,
manual_on_board: None,
vehicle_ejection: None,
}
}
pub fn enter(&mut self) {
@@ -84,3 +92,20 @@ impl Runtime {
Some(output)
}
}
#[cfg(test)]
mod vehicle_tests {
use super::*;
fn matrix() -> AnimationPartTransform { [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[3.,4.,5.,0.]] }
#[test]
fn vehicle_momentum_survives_reset_reply_and_is_consumed_once() {
let mut state=Runtime::new(Checkpoint{transform:matrix(),on_board:true});
state.request_vehicle_ejection(matrix(),[12.,2.,-3.],[0.,1.,0.]);
assert!(state.take_reply().is_some());
assert_eq!(state.take_manual_on_board(),Some(false));
assert_eq!(state.take_vehicle_ejection(),Some(([12.,2.,-3.],[0.,1.,0.])));
assert_eq!(state.take_vehicle_ejection(),None);
state.request_vehicle_ejection(matrix(),[12.,2.,-3.],[0.,1.,0.]);
state.request_manual(matrix(),true);
assert_eq!(state.take_vehicle_ejection(),None);
}
}
+2 -1
View File
@@ -152,6 +152,7 @@ fn axis(value: f32) -> f32 {
}
fn controls(
vehicles: Res<crate::modding::vehicles::Vehicles>,
mut replay: ResMut<Replay>,
mut input: ResMut<crate::input::ControllerInput>,
keys: Res<ButtonInput<KeyCode>>,
@@ -169,7 +170,7 @@ fn controls(
let pressed = pad.buttons & !replay.previous_buttons;
replay.previous_buttons = pad.buttons;
let menu_open = !crate::graphics_menu::gameplay_active(menu);
if !menu_open {
if !menu_open && !vehicles.occupied() {
if pressed & SELECT != 0 || keys.just_pressed(KeyCode::F6) {
if replay.active {
replay.exit();
@@ -97,6 +97,7 @@ fn suspend(
}
fn update(
vehicles: Res<crate::modding::vehicles::Vehicles>,
mut session: ResMut<SessionMarker>,
input: Res<ControllerInput>,
map: Res<CurrentMap>,
@@ -106,6 +107,7 @@ fn update(
replay: Res<crate::replay::Replay>,
mut audio: MessageWriter<SessionMarkerAudio>,
) {
if vehicles.occupied() {session.blocked_until_release = true;return;}
if replay.active {
return;
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "skate-mods"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
blake3 = "1.8.2"
mlua = { version = "=0.12.1", features = ["lua54", "vendored", "serde", "send"] }
serde.workspace = true
serde_json.workspace = true
skate-vehicles = { path = "../skate-vehicles" }
zip = { version = "6.0.0", default-features = false, features = ["deflate"] }
+6
View File
@@ -0,0 +1,6 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::args_os().nth(1).ok_or("Usage: check_mod path/to/mod.zip-or-folder")?;
let manifest = skate_mods::validate_package(std::path::Path::new(&path))?;
println!("Valid package: {} {} (API {})", manifest.id, manifest.version, manifest.api);
Ok(())
}
+75
View File
@@ -0,0 +1,75 @@
local submit = sdk._submit
sdk._submit = nil
function sdk.log(text) submit{kind="log",text=text} end
sdk.ui = {}
function sdk.ui.text(key,text) submit{kind="overlay",key=key,text=text} end
sdk.scene = {}
function sdk.scene.cube(key,position,size,color)
submit{kind="cube",key=key,position=position,size=size,color=color}
end
function sdk.scene.remove(key) submit{kind="remove",key=key} end
sdk.player = {}
function sdk.player.read() return sdk.snapshot.player end
function sdk.player.teleport(position,heading,on_board)
submit{kind="teleport",position=position,heading=heading,on_board=on_board}
end
sdk.input = {}
function sdk.input.down(key) return sdk.snapshot.keys[key] == true end
-- Timers use active Update time. Reusing a key replaces the old timer.
sdk.time = { elapsed = 0 }
local timers = {}
function sdk.time.after(key,seconds,callback)
assert(type(key)=='string' and #key>0 and #key<=64,'invalid timer key')
assert(type(seconds)=='number' and seconds>=0 and seconds<=86400,'invalid timer delay')
assert(type(callback)=='function','timer callback must be a function')
local count=0; for _ in pairs(timers) do count=count+1 end
assert(timers[key] or count<64,'64 timers maximum')
timers[key]={at=sdk.time.elapsed+seconds,callback=callback}
end
function sdk.time.cancel(key) timers[key]=nil end
function sdk._advance(dt)
sdk.time.elapsed=sdk.time.elapsed+dt
local due={}
for key,timer in pairs(timers) do if timer.at<=sdk.time.elapsed then due[#due+1]=key end end
table.sort(due)
for _,key in ipairs(due) do
local timer=timers[key]
if timer and timer.at<=sdk.time.elapsed then timers[key]=nil; timer.callback() end
end
end
function sdk.input.action(id)
assert(type(id)=='number' and id%1==0 and id>=64 and id<=81,'action ID must be 64..81')
return sdk.snapshot.actions[id-63]
end
sdk.animation = {}
function sdk.animation.replace(path) submit{kind="animation",path=path} end
function sdk.animation.info() return sdk.snapshot.animation end
sdk.trainer = {}
function sdk.trainer.apply(tuning) submit{kind="trainer",tuning=tuning} end
sdk.vehicle = {}
function sdk.vehicle.spawn(key,definition,position,heading) submit{kind="vehicle_spawn",key=key,definition=definition,position=position,heading=heading or 0} end
function sdk.vehicle.remove(key) submit{kind="vehicle_remove",key=key} end
function sdk.vehicle.enter(key) submit{kind="vehicle_enter",key=key} end
function sdk.vehicle.exit(key) submit{kind="vehicle_exit",key=key} end
function sdk.vehicle.reset(key,position,heading) submit{kind="vehicle_reset",key=key,position=position,heading=heading or 0} end
function sdk.vehicle.control(key,controls) submit{kind="vehicle_control",key=key,controls=controls} end
function sdk.vehicle.read(key)
local owned = (sdk.snapshot.vehicles or {})[sdk.mod_id]
return owned and owned[key] or nil
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
+99
View File
@@ -0,0 +1,99 @@
//! Bounded ZIP extraction. A session cache never overwrites another package.
use std::{collections::{BTreeMap, BTreeSet}, io::{Read, Write}, path::{Path, PathBuf}};
const LIMIT: u64 = 64 * 1024 * 1024;
#[derive(Default)]
pub(crate) struct Cache {
session: Option<PathBuf>,
entries: BTreeMap<PathBuf, (blake3::Hash, PathBuf)>,
next: u64,
}
fn safe_name(name: &str) -> Result<PathBuf, String> {
let name = name.strip_suffix('/').unwrap_or(name);
if name.is_empty() || name.len() > 240 || name.contains(['\\', ':', '<', '>', '"', '|', '?', '*'])
|| name.chars().any(char::is_control) {
return Err("Invalid ZIP path".into());
}
for part in name.split('/') {
let stem = part.split('.').next().unwrap_or("").to_ascii_lowercase();
if part.is_empty() || part == "." || part == ".." || part.ends_with(['.', ' '])
|| matches!(stem.as_str(), "con" | "prn" | "aux" | "nul" | "com1" | "com2" | "com3" | "com4" | "com5" | "com6" | "com7" | "com8" | "com9" | "lpt1" | "lpt2" | "lpt3" | "lpt4" | "lpt5" | "lpt6" | "lpt7" | "lpt8" | "lpt9") {
return Err("ZIP paths must be ordinary relative paths without traversal or device names".into());
}
}
Ok(PathBuf::from(name))
}
impl Cache {
pub fn materialize(&mut self, root: &Path, archive: &Path) -> Result<PathBuf, String> {
let mut bytes = Vec::new();
std::fs::File::open(archive).map_err(|e| e.to_string())?.take(LIMIT + 1)
.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
if bytes.len() as u64 > LIMIT { return Err("ZIP exceeds 64 MiB compressed".into()); }
let hash = blake3::hash(&bytes);
if let Some((old, path)) = self.entries.get(archive) {
if old == &hash { return Ok(path.clone()); }
}
// Validate and decompress into bounded memory before publishing anything.
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).map_err(|e| e.to_string())?;
if zip.len() > 512 { return Err("ZIP exceeds 512 entries".into()); }
let mut names = BTreeSet::new();
let mut files = Vec::new();
let mut total = 0_u64;
for i in 0..zip.len() {
let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
let path = safe_name(file.name())?;
if !names.insert(path.to_string_lossy().to_lowercase()) { return Err("Duplicate/case-colliding ZIP path".into()); }
let kind = file.unix_mode().unwrap_or(0) & 0o170000;
if kind != 0 && kind != 0o100000 && kind != 0o040000 { return Err("ZIP links and special files are unsupported".into()); }
if file.is_dir() { continue; }
if file.size() > LIMIT - total { return Err("ZIP exceeds 64 MiB expanded".into()); }
let mut contents = Vec::new();
file.by_ref().take(LIMIT - total + 1).read_to_end(&mut contents).map_err(|e| e.to_string())?;
total += contents.len() as u64;
if total > LIMIT { return Err("ZIP exceeds 64 MiB expanded".into()); }
files.push((path, contents));
}
if !files.iter().any(|(p, _)| p == Path::new("mod.json")) {
return Err("ZIP must contain mod.json at its root, not inside a wrapper folder".into());
}
if self.session.is_none() {
let root = root.canonicalize().map_err(|e| e.to_string())?;
let cache = root.join(".cache");
if cache.exists() && std::fs::symlink_metadata(&cache).map_err(|e|e.to_string())?.file_type().is_symlink() {
return Err("ZIP cache must not be a link".into());
}
std::fs::create_dir_all(&cache).map_err(|e| e.to_string())?;
if !cache.canonicalize().map_err(|e|e.to_string())?.starts_with(&root) { return Err("ZIP cache escapes mods folder".into()); }
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e|e.to_string())?.as_nanos();
let session = cache.join(format!("{}-{nonce}", std::process::id()));
std::fs::create_dir(&session).map_err(|e| e.to_string())?;
self.session = Some(session);
}
self.next += 1;
let destination = self.session.as_ref().unwrap().join(self.next.to_string());
std::fs::create_dir(&destination).map_err(|e| e.to_string())?;
for (path, contents) in files {
let target = destination.join(path);
std::fs::create_dir_all(target.parent().unwrap()).map_err(|e| e.to_string())?;
std::fs::OpenOptions::new().write(true).create_new(true).open(target)
.and_then(|mut f| f.write_all(&contents)).map_err(|e| e.to_string())?;
}
self.entries.insert(archive.to_owned(), (hash, destination.clone()));
Ok(destination)
}
}
impl Drop for Cache {
fn drop(&mut self) {
if let Some(path) = &self.session {
// Only this process's uniquely created session, and never a redirected path.
if let (Ok(actual), Ok(parent)) = (path.canonicalize(), path.parent().unwrap().canonicalize()) {
if actual.parent() == Some(parent.as_path()) && actual.file_name() == path.file_name() {
let _ = std::fs::remove_dir_all(path);
}
}
}
}
}
+483
View File
@@ -0,0 +1,483 @@
//! Window-free, versioned Lua package runtime. Host commands commit only after callbacks succeed.
mod schema;
mod archive;
mod vm;
pub use schema::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
time::{Duration, Instant},
};
pub use vm::Command;
/// Inspect a folder or ZIP and compile its Lua entry without executing callbacks.
pub fn validate_package(source: &Path) -> Result<Manifest, String> {
let source = source.canonicalize().map_err(|e| e.to_string())?;
let mut cache = archive::Cache::default();
let root = if source.is_dir() { source.clone() } else {
cache.materialize(source.parent().ok_or("Missing package parent")?, &source)?
};
let manifest: Manifest = serde_json::from_slice(&read_bounded(&root, "mod.json", 64 * 1024)?)
.map_err(|e| e.to_string())?;
manifest.validate()?;
fingerprint(&root)?;
let source = read_bounded(&root, &manifest.entry, 256 * 1024)?;
let source = std::str::from_utf8(&source).map_err(|e| e.to_string())?;
if source.starts_with('\u{1b}') { return Err("Lua bytecode is unsupported".into()); }
mlua::Lua::new().load(source).set_mode(mlua::chunk::ChunkMode::Text).into_function().map_err(|e|e.to_string())?;
Ok(manifest)
}
#[derive(Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Preferences {
enabled: bool,
#[serde(default)]
values: BTreeMap<String, Value>,
}
pub struct Package {
pub manifest: Manifest,
pub root: PathBuf,
pub error: Option<String>,
pub settings: BTreeMap<String, Value>,
pub enabled: bool,
vm: Option<vm::Vm>,
fingerprint: u64,
pending: Option<(u64, Instant)>,
}
impl Package {
pub fn content_fingerprint(&self) -> u64 { self.fingerprint }
pub fn running(&self) -> bool {
self.vm.is_some()
}
}
pub struct Manager {
pub packages: BTreeMap<String, Package>,
pub diagnostics: Vec<String>,
root: PathBuf,
preferences: PathBuf,
pub commands: Vec<(String, Command)>,
pub retired: Vec<String>,
last_scan: Instant,
pub snapshot: Value,
invalid_since: BTreeMap<String, Instant>,
archives: archive::Cache,
}
impl Manager {
pub fn root(&self) -> &Path {
&self.root
}
pub fn new(root: PathBuf, preferences: PathBuf) -> Self {
Self {
packages: BTreeMap::new(),
diagnostics: vec![],
root,
preferences,
commands: vec![],
retired: vec![],
last_scan: Instant::now() - Duration::from_secs(2),
snapshot: Value::Null,
invalid_since: BTreeMap::new(),
archives: archive::Cache::default(),
}
}
pub fn scan(&mut self, force: bool) {
if !force && self.last_scan.elapsed() < Duration::from_millis(500) {
return;
}
self.last_scan = Instant::now();
self.diagnostics.clear();
let mut found = BTreeMap::<String, (PathBuf, Manifest, u64)>::new();
let mut duplicates = std::collections::BTreeSet::new();
let dirs = match std::fs::read_dir(&self.root) {
Ok(d) => d,
Err(e) => {
self.diagnostics
.push(format!("Mods directory {}: {e}", self.root.display()));
if e.kind() == std::io::ErrorKind::NotFound {
let ids: Vec<_> = self.packages.keys().cloned().collect();
for id in ids {
self.stop(&id);
self.packages.remove(&id);
}
}
return;
}
};
let mut paths: Vec<_> = dirs
.filter_map(Result::ok)
.map(|d| d.path())
.filter(|p| !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
&& (p.is_dir() || p.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))))
.collect();
paths.sort();
if paths.len() > 128 {
self.diagnostics.push(
"Only the first 128 packages are supported; remove excess packages"
.into(),
);
}
for source in paths.into_iter().take(128) {
let path = if source.is_file() {
match self.archives.materialize(&self.root, &source) {
Ok(path) => path,
Err(e) => { self.diagnostics.push(format!("{}: {e}", source.display())); continue; }
}
} else { source.clone() };
// Invalid edits retire the old package: never silently retain outdated gameplay.
let result = (|| {
let bytes = read_bounded(&path, "mod.json", 64 * 1024)?;
let manifest: Manifest =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
manifest.validate()?;
let hash = fingerprint(&path)?;
Ok::<_, String>((manifest, hash))
})();
match result {
Ok((manifest, hash)) => {
let id = manifest.id.clone();
if found.contains_key(&id) {
duplicates.insert(id.clone());
}
found.insert(id, (path, manifest, hash));
}
Err(e) => self.diagnostics.push(format!("{}: {e}", source.display())),
}
}
for id in duplicates {
found.remove(&id);
self.diagnostics
.push(format!("Duplicate mod ID {id}: all copies rejected"));
}
let removed: Vec<_> = self
.packages
.iter()
.filter_map(|(id, p)| {
if found.contains_key(id) {
self.invalid_since.remove(id);
return None;
}
if !p.root.exists() || force {
return Some(id.clone());
}
let since = self
.invalid_since
.entry(id.clone())
.or_insert_with(Instant::now);
(since.elapsed() >= Duration::from_millis(750)).then(|| id.clone())
})
.collect();
for id in removed {
self.stop(&id);
self.packages.remove(&id);
}
for (id, (root, manifest, hash)) in found {
if let Some(p) = self.packages.get_mut(&id) {
if p.fingerprint == hash {
p.pending = None;
continue;
}
let ready = match p.pending {
Some((h, since)) if h == hash => since.elapsed() >= Duration::from_millis(750),
_ => {
p.pending = Some((hash, Instant::now()));
false
}
};
if !force && !ready {
continue;
}
let enabled = p.enabled;
self.stop(&id);
let p = self.packages.get_mut(&id).unwrap();
p.manifest = manifest;
p.root = root;
p.fingerprint = hash;
p.pending = None;
p.settings = p
.manifest
.settings
.iter()
.map(|(k, s)| {
(
k.clone(),
p.settings
.get(k)
.filter(|v| s.accepts(v))
.cloned()
.unwrap_or_else(|| s.default.clone()),
)
})
.collect();
if enabled {
self.start(&id);
}
} else {
let saved = self.read_preferences(&id);
let settings = manifest
.settings
.iter()
.map(|(k, s)| {
(
k.clone(),
saved
.values
.get(k)
.filter(|v| s.accepts(v))
.cloned()
.unwrap_or_else(|| s.default.clone()),
)
})
.collect();
let enabled = saved.enabled;
self.packages.insert(
id.clone(),
Package {
root,
manifest,
fingerprint: hash,
pending: None,
error: None,
settings,
enabled,
vm: None,
},
);
if enabled {
self.start(&id);
}
}
}
}
fn read_preferences(&mut self, id: &str) -> Preferences {
match std::fs::read(self.preferences.join(format!("{id}.json"))) {
Ok(b) => serde_json::from_slice(&b).unwrap_or_else(|e| {
self.diagnostics
.push(format!("Settings {id}: {e}; defaults used"));
Preferences::default()
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Preferences::default(),
Err(e) => {
self.diagnostics.push(format!("Settings {id}: {e}"));
Preferences::default()
}
}
}
fn save(&self, id: &str) -> Result<(), String> {
let p = self.packages.get(id).ok_or("Unknown mod")?;
std::fs::create_dir_all(&self.preferences).map_err(|e| e.to_string())?;
let file = self.preferences.join(format!("{id}.json"));
let temp = self.preferences.join(format!("{id}.tmp"));
std::fs::write(
&temp,
serde_json::to_vec_pretty(&Preferences {
enabled: p.enabled,
values: p.settings.clone(),
})
.unwrap(),
)
.map_err(|e| e.to_string())?;
std::fs::rename(temp, file).map_err(|e| e.to_string())
}
pub fn enable(&mut self, id: &str, enabled: bool) -> Result<(), String> {
self.packages.get_mut(id).ok_or("Unknown mod")?.enabled = enabled;
if enabled {
self.reload(id);
} else {
self.stop(id);
}
self.save(id)
}
pub fn reload(&mut self, id: &str) {
self.stop(id);
if self.packages.get(id).is_some_and(|p| p.enabled) {
self.start(id);
}
}
fn start(&mut self, id: &str) {
let Some(p) = self.packages.get_mut(id) else {
return;
};
p.error = None;
match vm::Vm::new(&p.root, &p.manifest, &p.settings, &self.snapshot) {
Ok(vm) => {
p.vm = Some(vm);
self.call_one(id, "on_load", Value::Null);
}
Err(e) => {
eprintln!("Lua [{id}] load: {e}");
p.error = Some(e);
self.retired.push(id.to_owned());
}
}
}
fn stop(&mut self, id: &str) {
if let Some(p) = self.packages.get_mut(id) {
if let Some(mut vm) = p.vm.take() {
if let Err(e) = vm.call("on_unload", Value::Null, &self.snapshot) {
p.error = Some(format!("on_unload: {e}"));
}
}
}
self.commands.retain(|(owner, _)| owner != id);
self.retired.push(id.to_owned());
}
pub fn fail(&mut self, id: &str, error: String) {
eprintln!("Lua [{id}]: {error}");
self.stop(id);
if let Some(p) = self.packages.get_mut(id) {
p.error = Some(error);
}
}
fn call_one(&mut self, id: &str, callback: &str, payload: Value) {
let result = self
.packages
.get_mut(id)
.and_then(|p| p.vm.as_mut())
.map(|vm| vm.call(callback, payload, &self.snapshot));
match result {
Some(Ok(cmds)) => self
.commands
.extend(cmds.into_iter().map(|c| (id.to_owned(), c))),
Some(Err(e)) => self.fail(id, format!("{callback}: {e}")),
None => {}
}
}
pub fn dispatch(&mut self, callback: &str, payload: Value) {
let ids: Vec<_> = self.packages.keys().cloned().collect();
for id in ids {
self.call_one(&id, callback, payload.clone());
}
}
pub fn setting(&mut self, id: &str, key: &str, value: Value) -> Result<(), String> {
let p = self.packages.get_mut(id).ok_or("Unknown mod")?;
if !p
.manifest
.settings
.get(key)
.ok_or("Unknown setting")?
.accepts(&value)
{
return Err("Invalid setting value".into());
}
p.settings.insert(key.into(), value.clone());
if let Some(vm) = p.vm.as_mut() {
if let Err(e) = vm.settings(&p.settings) {
let e = e.to_string();
self.fail(id, e.clone());
return Err(e);
}
}
self.call_one(
id,
"on_settings",
serde_json::json!({"key":key,"value":value}),
);
self.save(id)
}
pub fn reset(&mut self, id: &str) -> Result<(), String> {
let values: Vec<_> = self
.packages
.get(id)
.ok_or("Unknown mod")?
.manifest
.settings
.iter()
.map(|(k, s)| (k.clone(), s.default.clone()))
.collect();
for (k, v) in values {
self.setting(id, &k, v)?;
}
Ok(())
}
}
fn fingerprint(root: &Path) -> Result<u64, String> {
use std::hash::{Hash, Hasher};
fn visit(
root: &Path,
dir: &Path,
h: &mut std::collections::hash_map::DefaultHasher,
count: &mut usize,
bytes: &mut u64,
) -> Result<(), String> {
let mut paths: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| e.to_string())?
.map(|p| p.map(|p| p.path()))
.collect::<Result<_, _>>()
.map_err(|e| e.to_string())?;
paths.sort();
for p in paths {
*count += 1;
if *count > 512 {
return Err("Package exceeds 512 files/directories".into());
}
if std::fs::symlink_metadata(&p)
.map_err(|e| e.to_string())?
.file_type()
.is_symlink()
{
return Err("Symlinks are not supported".into());
}
let canonical = p.canonicalize().map_err(|e| e.to_string())?;
if !canonical.starts_with(root) {
return Err("Asset escapes mod root".into());
}
if p.is_dir() {
visit(root, &p, h, count, bytes)?;
} else {
let m = p.metadata().map_err(|e| e.to_string())?;
*bytes += m.len();
if *bytes > 64 * 1024 * 1024 {
return Err("Package exceeds 64 MiB".into());
}
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);
}
}
Ok(())
}
let root = root.canonicalize().map_err(|e| e.to_string())?;
let mut h = std::collections::hash_map::DefaultHasher::new();
visit(&root, &root, &mut h, &mut 0, &mut 0)?;
Ok(h.finish())
}
pub fn read_bounded(root: &Path, relative: &str, limit: u64) -> Result<Vec<u8>, String> {
let path = Path::new(relative);
if path
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|| relative.contains(':')
{
return Err("Use a relative path without traversal".into());
}
let root = root.canonicalize().map_err(|e| e.to_string())?;
let path = root.join(path).canonicalize().map_err(|e| e.to_string())?;
if !path.starts_with(&root) {
return Err("Asset escapes mod root".into());
}
use std::io::Read;
let mut bytes = vec![];
std::fs::File::open(path)
.map_err(|e| e.to_string())?
.take(limit + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() as u64 > limit {
return Err("Asset too large".into());
}
Ok(bytes)
}
impl Drop for Manager {
fn drop(&mut self) {
for package in self.packages.values_mut() {
if let Some(mut vm) = package.vm.take() {
let _ = vm.call("on_unload", Value::Null, &self.snapshot);
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub id: String,
pub api: u32,
pub name: String,
pub version: String,
pub author: String,
pub description: String,
pub entry: String,
#[serde(default)]
pub settings: BTreeMap<String, Setting>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Setting {
pub label: String,
pub description: String,
#[serde(rename = "type")]
pub kind: String,
pub default: Value,
pub min: Option<f64>,
pub max: Option<f64>,
pub step: Option<f64>,
#[serde(default)]
pub choices: Vec<String>,
}
pub fn valid_id(s: &str) -> bool {
let stem = s.split('.').next().unwrap_or("");
let reserved = matches!(
stem,
"con"
| "prn"
| "aux"
| "nul"
| "com1"
| "com2"
| "com3"
| "com4"
| "com5"
| "com6"
| "com7"
| "com8"
| "com9"
| "lpt1"
| "lpt2"
| "lpt3"
| "lpt4"
| "lpt5"
| "lpt6"
| "lpt7"
| "lpt8"
| "lpt9"
);
!reserved
&& !s.is_empty()
&& s.len() <= 64
&& s.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-' || b == b'.'
})
&& s != "."
&& s != ".."
}
impl Manifest {
pub fn validate(&self) -> Result<(), String> {
if !valid_id(&self.id) || self.api != 1 {
return Err("Invalid ID or unsupported API (expected 1)".into());
}
if self.name.is_empty()
|| self.name.len() > 120
|| self.author.len() > 120
|| self.description.len() > 2048
{
return Err("Invalid metadata length".into());
}
if self.version.split('.').count() != 3
|| self.version.split('.').any(|p| p.parse::<u32>().is_err())
{
return Err("version must be MAJOR.MINOR.PATCH".into());
}
if self.settings.len() > 24 {
return Err("At most 24 settings".into());
}
for (key, s) in &self.settings {
if !valid_id(key) || s.label.len() > 120 || s.description.len() > 512 {
return Err(format!("Invalid setting {key}"));
}
if s.kind == "number"
&& !(s
.min
.zip(s.max)
.is_some_and(|(a, b)| a.is_finite() && b.is_finite() && a <= b)
&& s.step.is_some_and(|v| v.is_finite() && v > 0.))
{
return Err(format!(
"{key}: numbers need finite min, max and positive step"
));
}
if s.kind == "choice"
&& (s.choices.is_empty()
|| s.choices.len() > 32
|| s.choices.iter().any(|s| s.len() > 128))
{
return Err(format!("{key}: invalid choices"));
}
if !s.accepts(&s.default) {
return Err(format!("{key}: invalid type/default"));
}
}
Ok(())
}
}
impl Setting {
pub fn accepts(&self, v: &Value) -> bool {
match self.kind.as_str() {
"boolean" => v.is_boolean(),
"number" => v.as_f64().is_some_and(|v| {
v.is_finite()
&& self.min.is_some_and(|a| v >= a)
&& self.max.is_some_and(|b| v <= b)
}),
"string" => v
.as_str()
.is_some_and(|s| s.chars().count() <= 128 && !s.chars().any(char::is_control)),
"choice" => v
.as_str()
.is_some_and(|s| self.choices.iter().any(|c| c == s)),
_ => false,
}
}
}
/// Whitelisted native trainer multipliers. 1.0 preserves the installed stock value.
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct TrainerTuning {
pub pop: f32,
pub grind_pop: f32,
pub push_speed: f32,
pub push_power: f32,
pub braking: f32,
pub steering: f32,
pub wobble: f32,
pub offboard_jump: f32,
pub grip: f32,
pub turn_power: f32,
pub manual_drag: f32,
pub hold_fakie: bool,
}
impl Default for TrainerTuning {
fn default() -> Self {
Self {
pop: 1.,
grind_pop: 1.,
push_speed: 1.,
push_power: 1.,
braking: 1.,
steering: 1.,
wobble: 1.,
offboard_jump: 1.,
grip: 1.,
turn_power: 1.,
manual_drag: 1.,
hold_fakie: false,
}
}
}
impl TrainerTuning {
pub fn valid(&self) -> bool {
[
self.pop,
self.grind_pop,
self.push_speed,
self.push_power,
self.braking,
self.steering,
self.offboard_jump,
self.grip,
self.turn_power,
self.manual_drag,
]
.into_iter()
.all(|v| v.is_finite() && (0.25..=4.).contains(&v))
&& self.wobble.is_finite()
&& (0. ..=2.).contains(&self.wobble)
}
}
+241
View File
@@ -0,0 +1,241 @@
use crate::{Manifest, read_bounded};
use mlua::{HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, Table, VmState};
use serde::Deserialize;
use serde_json::Value;
use std::{
collections::BTreeMap,
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
};
#[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 },
VehicleReset { key:String, position:[f32;3], heading:f32 },
VehicleControl { key:String, controls:skate_vehicles::Controls },
Trainer {
tuning: crate::TrainerTuning,
},
Log {
text: String,
},
Animation {
path: String,
},
Overlay {
key: String,
text: String,
},
Cube {
key: String,
position: [f32; 3],
size: [f32; 3],
color: [f32; 3],
},
Remove {
key: String,
},
Teleport {
position: [f32; 3],
heading: f32,
on_board: bool,
},
}
impl Command {
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(),
Self::VehicleControl{key,controls} => crate::schema::valid_id(key) && controls.valid(),
Self::VehicleRemove{key}|Self::VehicleEnter{key}|Self::VehicleExit{key} => crate::schema::valid_id(key),
Self::Trainer { tuning } => tuning.valid(),
Self::Animation { path } => !path.is_empty() && path.len() <= 256,
Self::Log { text } => text.len() <= 2048,
Self::Overlay { key, text } => crate::schema::valid_id(key) && text.len() <= 1024,
Self::Cube {
key,
position,
size,
color,
} => {
crate::schema::valid_id(key)
&& point(position)
&& size.iter().all(|v| v.is_finite() && *v > 0. && *v <= 100.)
&& color
.iter()
.all(|v| v.is_finite() && (0. ..=1.).contains(v))
}
Self::Remove { key } => crate::schema::valid_id(key),
Self::Teleport {
position, heading, ..
} => point(position) && heading.is_finite(),
}
}
}
pub(crate) struct Vm {
lua: Lua,
callbacks: Table,
advance: mlua::Function,
budget: Arc<AtomicUsize>,
queue: Arc<Mutex<Vec<Command>>>,
}
impl Vm {
pub fn new(
root: &Path,
manifest: &Manifest,
settings: &BTreeMap<String, Value>,
snapshot: &Value,
) -> Result<Self, String> {
let build = || -> mlua::Result<Self> {
let lua = Lua::new_with(
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8,
LuaOptions::default(),
)?;
lua.set_memory_limit(8 * 1024 * 1024)?;
// No catch/retry loops around quota errors, coroutine bypass, bytecode or filesystem loaders.
for key in [
"pcall",
"xpcall",
"load",
"loadfile",
"dofile",
"collectgarbage",
"print",
] {
lua.globals().set(key, mlua::Value::Nil)?;
}
let budget = Arc::new(AtomicUsize::new(100));
let counter = budget.clone();
lua.set_hook(
HookTriggers::new().every_nth_instruction(1000),
move |_, _| {
if counter
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1))
.is_err()
{
return Err(mlua::Error::RuntimeError(
"Lua instruction budget exhausted".into(),
));
}
Ok(VmState::Continue)
},
)?;
let queue = Arc::new(Mutex::new(Vec::<Command>::new()));
let out = queue.clone();
let sdk = lua.create_table()?;
sdk.set("api_version", 1)?;
sdk.set("mod_id", manifest.id.clone())?;
sdk.set(
"_submit",
lua.create_function(move |lua, value: mlua::Value| {
let c: Command = lua.from_value(value)?;
if !c.validate() {
return Err(mlua::Error::RuntimeError(
"Invalid command arguments".into(),
));
}
let mut q = out.lock().unwrap();
if q.len() >= 128 {
return Err(mlua::Error::RuntimeError(
"128 commands per callback maximum".into(),
));
}
q.push(c);
Ok(())
})?,
)?;
let asset_root = root.to_path_buf();
sdk.set(
"read_text",
lua.create_function(move |_, path: String| {
let b = read_bounded(&asset_root, &path, 256 * 1024)
.map_err(mlua::Error::RuntimeError)?;
String::from_utf8(b).map_err(mlua::Error::external)
})?,
)?;
sdk.set("settings", lua.to_value(settings)?)?;
sdk.set("snapshot", lua.to_value(snapshot)?)?;
lua.globals().set("sdk", sdk)?;
lua.load(include_str!("api.lua"))
.set_name("@skate-sdk-1")
.exec()?;
let sdk: Table = lua.globals().get("sdk")?;
let advance = sdk.get("_advance")?;
sdk.set("_advance", mlua::Value::Nil)?;
let code = crate::read_bounded(root, &manifest.entry, 256 * 1024)
.map_err(mlua::Error::RuntimeError)?;
let source = std::str::from_utf8(&code).map_err(mlua::Error::external)?;
let callbacks: Table = lua
.load(source)
.set_name(format!("@{}/{}", manifest.id, manifest.entry))
.eval()?;
for pair in callbacks.clone().pairs::<String, mlua::Value>() {
let (key, value) = pair?;
if ![
"on_load",
"on_unload",
"on_update",
"on_fixed_update",
"on_event",
"on_settings",
]
.contains(&key.as_str())
|| !matches!(value, mlua::Value::Function(_))
{
return Err(mlua::Error::RuntimeError(format!(
"Unknown callback or non-function: {key}"
)));
}
}
// Top-level commands are staged together with on_load, not committed yet.
Ok(Self {
lua,
callbacks,
advance,
budget,
queue,
})
};
build().map_err(|e| e.to_string())
}
pub fn settings(&mut self, settings: &BTreeMap<String, Value>) -> mlua::Result<()> {
self.lua
.globals()
.get::<Table>("sdk")?
.set("settings", self.lua.to_value(settings)?)
}
pub fn call(
&mut self,
name: &str,
payload: Value,
snapshot: &Value,
) -> Result<Vec<Command>, String> {
self.budget.store(100, Ordering::Relaxed);
let invoke = || -> mlua::Result<()> {
self.lua
.globals()
.get::<Table>("sdk")?
.set("snapshot", self.lua.to_value(snapshot)?)?;
if let Some(f) = self.callbacks.get::<Option<mlua::Function>>(name)? {
f.call::<()>(self.lua.to_value(&payload)?)?;
}
if name == "on_update" {
self.advance
.call::<()>(payload["dt"].as_f64().unwrap_or(0.))?;
}
Ok(())
};
let result = invoke();
let commands = std::mem::take(&mut *self.queue.lock().unwrap());
result.map(|_| commands).map_err(|e| e.to_string())
}
}
+85
View File
@@ -0,0 +1,85 @@
use skate_mods::Manager;
use std::{io::Write, path::PathBuf, sync::atomic::{AtomicUsize, Ordering}};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!("skate-zip-{}-{}", std::process::id(), NEXT.fetch_add(1,Ordering::Relaxed)));
std::fs::create_dir_all(&root).unwrap(); Self(root)
}
fn zip(&self, name: &str, files: &[(&str, &[u8])]) {
let mut zip = zip::ZipWriter::new(std::fs::File::create(self.0.join(name)).unwrap());
for (name, contents) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)).unwrap();
zip.write_all(contents).unwrap();
}
zip.finish().unwrap();
}
fn manager(&self) -> Manager { Manager::new(self.0.clone(), self.0.join(".settings")) }
}
impl Drop for Fixture {
fn drop(&mut self) {
let actual=self.0.canonicalize().unwrap();
let temp=std::env::temp_dir().canonicalize().unwrap();
assert!(actual.parent()==Some(temp.as_path()));
std::fs::remove_dir_all(actual).unwrap();
}
}
const MANIFEST: &[u8] = br#"{"id":"zip.example","api":1,"name":"ZIP Example","version":"1.0.0","author":"Test","description":"Test","entry":"main.lua"}"#;
#[test]
fn zip_assets_reload_and_removal_follow_the_archive() {
let f=Fixture::new();
let code=b"return {on_load=function() sdk.log(sdk.read_text('assets/message.txt')) end}";
f.zip("example.zip", &[("mod.json",MANIFEST),("main.lua",code),("assets/message.txt",b"first")]);
let mut m=f.manager(); m.scan(true); assert!(m.diagnostics.is_empty(), "{:?}",m.diagnostics);
m.enable("zip.example",true).unwrap(); assert!(m.packages["zip.example"].running());
let old=m.packages["zip.example"].root.clone();
assert_eq!(std::fs::read(old.join("assets/message.txt")).unwrap(),b"first");
m.scan(true); assert_eq!(m.packages["zip.example"].root,old);
f.zip("example.zip", &[("mod.json",MANIFEST),("main.lua",code),("assets/message.txt",b"second")]);
m.scan(true); assert_ne!(m.packages["zip.example"].root,old);
assert!(m.packages["zip.example"].running());
std::fs::remove_file(f.0.join("example.zip")).unwrap(); m.scan(true);
assert!(m.packages.is_empty()); assert!(m.retired.contains(&"zip.example".to_owned()));
}
#[test]
fn unsafe_paths_wrapper_folders_and_case_collisions_are_rejected() {
for name in ["../escape.txt", "/absolute.txt", "C:/escape.txt", "a\\escape.txt", "nul.txt", "a./x", "a/../x"] {
let f=Fixture::new(); f.zip("bad.zip", &[("mod.json",MANIFEST),(name,b"bad")]);
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty(),"{name}"); assert!(!m.diagnostics.is_empty());
}
for files in [vec![("wrapper/mod.json",MANIFEST)], vec![("mod.json",MANIFEST),("MOD.JSON",MANIFEST)]] {
let f=Fixture::new(); f.zip("bad.zip", &files);
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty()); assert!(!m.diagnostics.is_empty());
}
}
#[test]
fn expansion_limits_and_duplicate_ids_are_rejected() {
let f=Fixture::new(); let huge=vec![0_u8;64*1024*1024+1];
f.zip("huge.zip", &[("mod.json",MANIFEST),("huge.bin",&huge)]);
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty());
assert!(m.diagnostics.iter().any(|s|s.contains("64 MiB")));
std::fs::remove_file(f.0.join("huge.zip")).unwrap();
for name in ["one.zip","two.zip"] { f.zip(name,&[("mod.json",MANIFEST),("main.lua",b"return {}")]); }
m.scan(true); assert!(m.packages.is_empty()); assert!(m.diagnostics.iter().any(|s|s.contains("Duplicate mod ID")));
}
#[test]
fn links_excess_entries_and_invalid_replacements_do_not_run() {
let f=Fixture::new();
let mut zip=zip::ZipWriter::new(std::fs::File::create(f.0.join("bad.zip")).unwrap());
zip.add_symlink("escape", "../outside", zip::write::SimpleFileOptions::default()).unwrap();
zip.finish().unwrap();
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty());
assert!(m.diagnostics.iter().any(|s|s.contains("links")));
let mut zip=zip::ZipWriter::new(std::fs::File::create(f.0.join("bad.zip")).unwrap());
for i in 0..513 { zip.start_file(format!("file-{i}"),zip::write::SimpleFileOptions::default()).unwrap(); }
zip.finish().unwrap(); m.scan(true); assert!(m.diagnostics.iter().any(|s|s.contains("512")));
f.zip("bad.zip", &[("mod.json",MANIFEST),("main.lua",b"return {}")]);
m.scan(true); m.enable("zip.example",true).unwrap(); assert!(m.packages["zip.example"].running());
std::fs::write(f.0.join("bad.zip"),b"not a ZIP").unwrap(); m.scan(true);
assert!(m.packages.is_empty()); assert!(!m.diagnostics.is_empty());
}
+476
View File
@@ -0,0 +1,476 @@
use serde_json::json;
use skate_mods::*;
use std::{
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new(code: &str) -> Self {
let p = std::env::temp_dir().join(format!(
"skate-sdk-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(p.join("mods/example")).unwrap();
let f = Self(p);
f.code(code);
std::fs::write(f.0.join("mods/example/mod.json"),serde_json::to_vec(&json!({"id":"example","api":1,"name":"Example","version":"1.0.0","author":"Test","description":"Test","entry":"main.lua","settings":{"count":{"type":"number","label":"Count","description":"Count","min":1,"max":10,"step":1,"default":3}}})).unwrap()).unwrap();
f
}
fn code(&self, s: &str) {
std::fs::write(self.0.join("mods/example/main.lua"), s).unwrap();
}
fn manager(&self) -> Manager {
let mut m = Manager::new(self.0.join("mods"), self.0.join("settings"));
m.scan(true);
m
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let temp = std::env::temp_dir().canonicalize().unwrap();
let target = self.0.canonicalize().unwrap();
assert!(target.starts_with(&temp) && target != temp);
assert!(
target
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("skate-sdk-")
);
let _ = std::fs::remove_dir_all(target);
}
}
#[test]
fn disabled_by_default_reload_resets_and_removes_stale_commands() {
let f =
Fixture::new("local n=0; return {on_update=function() n=n+1; sdk.log(tostring(n)) end}");
let mut m = f.manager();
assert!(!m.packages["example"].running());
m.enable("example", true).unwrap();
m.dispatch("on_update", json!({}));
m.dispatch("on_update", json!({}));
assert_eq!(m.commands.len(), 2);
m.reload("example");
assert!(m.commands.is_empty());
m.dispatch("on_update", json!({}));
assert!(matches!(&m.commands[0].1,Command::Log{text} if text=="1"));
m.enable("example", false).unwrap();
assert!(m.commands.is_empty());
assert!(!m.packages["example"].running());
}
#[test]
fn failed_callback_discards_all_commands_and_stops_mod() {
let f =
Fixture::new("return {on_load=function() sdk.ui.text('test','before'); error('fail') end}");
let mut m = f.manager();
m.enable("example", true).unwrap();
assert!(m.commands.is_empty());
assert!(!m.packages["example"].running());
assert!(
m.packages["example"]
.error
.as_ref()
.unwrap()
.contains("fail")
);
}
#[test]
fn instruction_and_memory_limits() {
for code in [
"return {on_load=function() while true do end end}",
"return {on_load=function() local t={} for i=1,100000 do t[i]=string.rep('a',10000) end end}",
] {
let f = Fixture::new(code);
let mut m = f.manager();
m.enable("example", true).unwrap();
assert!(m.packages["example"].error.is_some());
assert!(!m.packages["example"].running());
}
}
#[test]
fn libraries_are_restricted() {
let f = Fixture::new(
"assert(io==nil and os==nil and debug==nil and package==nil and require==nil and load==nil and pcall==nil and coroutine==nil); return {}",
);
let mut m = f.manager();
m.enable("example", true).unwrap();
assert!(
m.packages["example"].running(),
"{:?}",
m.packages["example"].error
);
}
#[test]
fn immediate_settings_persist_and_validate() {
let f = Fixture::new(
"return {on_settings=function(e) sdk.log(tostring(sdk.settings.count)..':'..e.key) end}",
);
let mut m = f.manager();
m.enable("example", true).unwrap();
assert!(m.setting("example", "count", json!(11)).is_err());
m.setting("example", "count", json!(7)).unwrap();
assert!(matches!(&m.commands.last().unwrap().1,Command::Log{text} if text=="7:count"));
let m = f.manager();
assert_eq!(m.packages["example"].settings["count"], json!(7));
assert!(m.packages["example"].running());
}
#[test]
fn files_changed_removed_and_invalid_manifest() {
let f = Fixture::new("return {}");
let mut m = f.manager();
m.enable("example", true).unwrap();
f.code("return {on_load=function() sdk.log('new') end}");
m.scan(true);
assert!(matches!(&m.commands[0].1,Command::Log{text} if text=="new"));
std::fs::write(f.0.join("mods/example/mod.json"), "{}").unwrap();
m.scan(true);
assert!(m.packages.is_empty());
assert!(!m.diagnostics.is_empty());
}
#[test]
fn traversal_and_unknown_dependencies_rejected() {
let f = Fixture::new("return {}");
assert!(read_bounded(&f.0, "../anything", 100).is_err());
assert!(read_bounded(&f.0, "C:/Windows/win.ini", 100).is_err());
let mut v: serde_json::Value =
serde_json::from_slice(&std::fs::read(f.0.join("mods/example/mod.json")).unwrap()).unwrap();
v["dependencies"] = json!(["other"]);
assert!(serde_json::from_value::<Manifest>(v).is_err());
}
#[test]
fn examples_load_and_run() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples");
if !root.exists() {
panic!("Examples missing");
}
let f = Fixture::new("return {}");
let mut m = Manager::new(root, f.0.join("examples-settings"));
m.snapshot = json!({"player":{"position":[0,0,0],"velocity":[0,0,0],"state":100,"on_board":true},"keys":{},"map":{"name":"test","generation":0},"tick":1,"actions":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]});
m.scan(true);
assert!(m.diagnostics.is_empty(), "{:?}", m.diagnostics);
let ids: Vec<_> = m.packages.keys().cloned().collect();
assert_eq!(ids, vec!["community.mario-kart", "community.native-trainer"]);
for id in ids {
m.enable(&id, true).unwrap();
for _ in 0..5 {
m.dispatch("on_update", json!({"dt":0.016}));
m.dispatch("on_fixed_update", json!({"dt":0.016}));
m.commands.clear();
}
assert!(m.packages[&id].running(), "{:?}", m.packages[&id].error);
}
}
#[test]
fn timers_are_ordered_replaced_and_reset() {
let f = Fixture::new(
"return {on_load=function() sdk.time.after('z',0,function() sdk.log('z') end); sdk.time.after('a',0,function() sdk.log('old') end); sdk.time.after('a',0,function() sdk.log('a') end) end}",
);
let mut m = f.manager();
m.enable("example", true).unwrap();
m.dispatch("on_update", json!({"dt":0.1}));
let logs: Vec<_> = m
.commands
.iter()
.filter_map(|(_, c)| {
if let Command::Log { text } = c {
Some(text.as_str())
} else {
None
}
})
.collect();
assert_eq!(logs, vec!["a", "z"]);
m.commands.clear();
m.dispatch("on_update", json!({"dt":0.1}));
assert!(m.commands.is_empty());
m.reload("example");
m.dispatch("on_update", json!({"dt":0.1}));
assert_eq!(m.commands.len(), 2);
}
#[test]
fn one_mod_error_does_not_stop_other_mods() {
let f = Fixture::new("return {on_update=function() error('oops') end}");
let other = f.0.join("mods/other");
std::fs::create_dir_all(&other).unwrap();
let mut manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(f.0.join("mods/example/mod.json")).unwrap()).unwrap();
manifest["id"] = json!("other");
std::fs::write(
other.join("mod.json"),
serde_json::to_vec(&manifest).unwrap(),
)
.unwrap();
std::fs::write(
other.join("main.lua"),
"return {on_update=function() sdk.log('alive') end}",
)
.unwrap();
let mut m = f.manager();
m.enable("example", true).unwrap();
m.enable("other", true).unwrap();
m.dispatch("on_update", json!({"dt":0.1}));
assert!(!m.packages["example"].running());
assert!(m.packages["other"].running());
assert_eq!(m.commands.len(), 1);
}
#[test]
fn duplicate_ids_and_unload_failure_cleanup() {
let f = Fixture::new("return {on_unload=function() sdk.log('discard'); error('unload') end}");
let mut m = f.manager();
m.enable("example", true).unwrap();
m.enable("example", false).unwrap();
assert!(m.commands.is_empty());
assert!(
m.packages["example"]
.error
.as_ref()
.unwrap()
.contains("unload")
);
let other = f.0.join("mods/other");
std::fs::create_dir_all(&other).unwrap();
std::fs::copy(f.0.join("mods/example/mod.json"), other.join("mod.json")).unwrap();
std::fs::write(other.join("main.lua"), "return {}").unwrap();
m.scan(true);
assert!(m.packages.is_empty());
assert!(m.diagnostics.iter().any(|s| s.contains("Duplicate")));
}
#[test]
fn schema_upgrade_keeps_only_compatible_keys() {
let f = Fixture::new("return {}");
let mut m = f.manager();
m.setting("example", "count", json!(7)).unwrap();
let path = f.0.join("mods/example/mod.json");
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
v["version"] = json!("2.0.0");
v["settings"]["count"]["max"] = json!(5);
v["settings"]["renamed"] = v["settings"]["count"].clone();
std::fs::write(&path, serde_json::to_vec(&v).unwrap()).unwrap();
m.scan(true);
assert_eq!(m.packages["example"].settings["count"], json!(3));
assert_eq!(m.packages["example"].settings["renamed"], json!(3));
}
#[test]
fn changed_source_waits_for_debounce() {
let f = Fixture::new("return {}");
let mut m = f.manager();
m.enable("example", true).unwrap();
f.code("return {on_load=function() sdk.log('changed') end}");
std::thread::sleep(std::time::Duration::from_millis(510));
m.scan(false);
assert!(m.commands.is_empty());
std::thread::sleep(std::time::Duration::from_millis(510));
m.scan(false);
assert!(m.commands.is_empty());
std::thread::sleep(std::time::Duration::from_millis(510));
m.scan(false);
assert_eq!(m.commands.len(), 1);
}
#[test]
fn trainer_commands_validate_and_follow_settings() {
let f = Fixture::new(
"return {on_load=function() sdk.trainer.apply{pop=2,push_speed=3,wobble=0} end, on_settings=function() sdk.trainer.apply{pop=sdk.settings.count} end}",
);
let mut m = f.manager();
m.enable("example", true).unwrap();
assert!(
matches!(&m.commands[0].1,Command::Trainer{tuning} if tuning.pop==2. && tuning.push_speed==3. && tuning.wobble==0. && tuning.braking==1.)
);
m.setting("example", "count", json!(4)).unwrap();
assert!(matches!(&m.commands.last().unwrap().1,Command::Trainer{tuning} if tuning.pop==4.));
m.setting("example", "count", json!(5)).unwrap();
assert!(!m.packages["example"].running());
assert!(m.commands.is_empty());
assert!(
!TrainerTuning {
pop: f32::NAN,
..Default::default()
}
.valid()
);
m.enable("example", false).unwrap();
assert!(m.retired.contains(&"example".into()));
}
fn showcase_manager(f: &Fixture) -> Manager {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples");
let mut m = Manager::new(root, f.0.join("showcase-settings"));
m.snapshot = json!({"player":{"position":[0,0,0],"velocity":[3,0,0],"heading":0.5,"state":100,"on_board":true,"bailing":false,"grind":{"active":false}},"keys":{},"map":{"name":"test","generation":0},"actions":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]});
m.scan(true);
m.enable("community.native-trainer", true).unwrap();
m.commands.clear();
m
}
#[test]
fn showcase_checkpoint_stopwatch_and_world_cleanup() {
let f = Fixture::new("return {}");
let mut m = showcase_manager(&f);
m.snapshot["keys"] = json!({"F5":true});
m.dispatch("on_update", json!({"dt":0.1}));
assert!(
m.commands
.iter()
.any(|(_, c)| matches!(c,Command::Cube{key,..} if key=="checkpoint"))
);
m.commands.clear();
m.snapshot["keys"] = json!({"F6":true,"F7":true});
m.dispatch("on_update", json!({"dt":0.1}));
assert!(
m.commands
.iter()
.any(|(_, c)| matches!(c,Command::Teleport{heading,..} if *heading==0.5))
);
assert!(
m.commands
.iter()
.any(|(_, c)| matches!(c,Command::Overlay{key,..} if key=="timer"))
);
m.commands.clear();
m.snapshot["keys"] = json!({});
m.dispatch("on_event", json!({"name":"world_changed"}));
assert!(
m.commands
.iter()
.any(|(_, c)| matches!(c,Command::Remove{key} if key=="checkpoint"))
);
m.commands.clear();
m.snapshot["keys"] = json!({"F6":true});
m.dispatch("on_update", json!({"dt":0.1}));
assert!(
!m.commands
.iter()
.any(|(_, c)| matches!(c, Command::Teleport { .. }))
);
assert!(m.packages["community.native-trainer"].running());
}
#[test]
fn showcase_reuses_trail_keys_and_updates_hud_settings() {
let f = Fixture::new("return {}");
let mut m = showcase_manager(&f);
let id = "community.native-trainer";
m.setting(id, "trail", json!(true)).unwrap();
m.commands.clear();
let mut trail = std::collections::BTreeSet::new();
for i in 0..100 {
m.snapshot["player"]["position"] = json!([i * 10, 0, 0]);
m.dispatch("on_update", json!({"dt":0.1}));
for (_, c) in &m.commands {
if let Command::Cube { key, .. } = c {
trail.insert(key.clone());
}
}
m.commands.clear();
}
assert_eq!(trail.len(), 24);
assert!(m.packages[id].running());
m.setting(id, "hud", json!("off")).unwrap();
assert!(
m.commands
.iter()
.any(|(_, c)| matches!(c,Command::Remove{key} if key=="hud"))
);
m.commands.clear();
m.setting(id, "grip", json!(2)).unwrap();
assert!(
m.commands
.iter()
.any(|(_, c)| matches!(c,Command::Trainer{tuning} if tuning.grip==2.))
);
}
#[test]
fn showcase_hold_fakie_is_optional_live_and_reversible() {
let f = Fixture::new("return {}");
let mut m = showcase_manager(&f);
let id = "community.native-trainer";
assert!(!TrainerTuning::default().hold_fakie);
assert_eq!(m.packages[id].settings["hold_fakie"], json!(false));
for enabled in [true, false] {
m.commands.clear();
m.setting(id, "hold_fakie", json!(enabled)).unwrap();
let tuning = m.commands.iter().find_map(|(_, command)| match command {
Command::Trainer { tuning } => Some(tuning),
_ => None,
}).expect("setting update must emit native trainer tuning");
assert_eq!(tuning.hold_fakie, enabled);
assert_eq!(tuning.pop, 1.);
assert!(tuning.valid());
}
m.setting(id, "hold_fakie", json!(true)).unwrap();
m.enable(id, false).unwrap();
assert!(m.retired.iter().any(|owner| owner == id));
}
#[test]
fn vehicle_api_validates_and_queues_owner_scoped_commands() {
let f=Fixture::new(r#"return {on_load=function()
sdk.vehicle.spawn('kart','vehicle.json',{0,2,0},0)
sdk.vehicle.tune('kart',{max_speed=20})
sdk.vehicle.control('kart',{throttle=1,steering=0.2})
sdk.vehicle.enter('kart');sdk.vehicle.exit('kart');sdk.vehicle.reset('kart',{0,2,0},0);sdk.vehicle.remove('kart')
end}"#);
let mut m=f.manager();m.enable("example",true).unwrap();assert_eq!(m.commands.len(),7);
assert!(m.commands.iter().all(|(owner,_)|owner=="example"));
assert!(matches!(&m.commands[0].1,Command::VehicleSpawn{definition,..} if definition=="vehicle.json"));
assert!(matches!(&m.commands[2].1,Command::VehicleControl{controls,..} if controls.throttle==1.));
m.enable("example",false).unwrap();assert!(m.commands.is_empty());
}
#[test]
fn vehicle_api_rejects_traversal_and_invalid_controls() {
for script in ["sdk.vehicle.spawn('kart','../vehicle.json',{0,0,0},0)","sdk.vehicle.control('kart',{throttle=2})","sdk.vehicle.tune('kart',{max_speed=-1})"] {
let f=Fixture::new(&format!("return {{on_load=function() {script} end}}"));let mut m=f.manager();m.enable("example",true).unwrap();assert!(!m.packages["example"].running());assert!(m.commands.is_empty());
}
}
#[test]
fn kart_controller_reset_is_edge_triggered_and_rebindable() {
let f=Fixture::new("return {}");let mut m=Manager::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples"),f.0.join("kart-settings"));
m.snapshot=json!({"player":{"position":[0,0,0],"heading":0},"keys":{},"vehicles":{"community.mario-kart":{"kart":{"position":[2,1,3],"heading":0.5,"occupied":true,"phase":"driving","speed":0,"ready":true}}},"vehicle_input":{"throttle":0,"steering":0,"brake":0,"handbrake":false,"interact":false,"pad_buttons":0}});
m.scan(true);m.enable("community.mario-kart",true).unwrap();m.commands.clear();
for buttons in [128,128,128,0,128] {m.snapshot["vehicle_input"]["pad_buttons"]=json!(buttons);m.dispatch("on_fixed_update",json!({"dt":0.016}));}
assert_eq!(m.commands.iter().filter(|(_,c)|matches!(c,Command::VehicleReset{..})).count(),2);
m.setting("community.mario-kart","reset_button",json!("Left stick")).unwrap();m.commands.clear();
for buttons in [0,128,0,64,64] {m.snapshot["vehicle_input"]["pad_buttons"]=json!(buttons);m.dispatch("on_fixed_update",json!({"dt":0.016}));}
assert_eq!(m.commands.iter().filter(|(_,c)|matches!(c,Command::VehicleReset{..})).count(),1);
assert!(m.packages["community.mario-kart"].running());
}
#[test]
fn fingerprints_are_portable_and_include_assets() {
let a=Fixture::new("return {}");let b=Fixture::new("return {}");
std::fs::write(a.0.join("mods/example/asset.txt"),"same").unwrap();
std::fs::write(b.0.join("mods/example/asset.txt"),"same").unwrap();
let first=a.manager().packages["example"].content_fingerprint();
assert_eq!(first,b.manager().packages["example"].content_fingerprint());
std::fs::write(b.0.join("mods/example/asset.txt"),"changed").unwrap();
assert_ne!(first,b.manager().packages["example"].content_fingerprint());
}
#[test]
fn shared_state_api_is_owner_scoped_and_transactional() {
let f=Fixture::new(r#"return {on_update=function()
assert(sdk.net.info().active)
assert(sdk.net.read("2","score")==7)
sdk.net.publish("score",8)
end}"#);
let mut m=f.manager();m.snapshot=json!({"network":{"active":true,"states":{"example":{"2":{"score":7}}}}});
m.enable("example",true).unwrap();m.dispatch("on_update",json!({"dt":0.1}));
assert!(matches!(&m.commands[0].1,Command::NetworkState{key,value} if key=="score" && value==8));
let f=Fixture::new(r#"return {on_update=function() sdk.net.publish("a",1); error("abort") end}"#);
let mut m=f.manager();m.enable("example",true).unwrap();m.dispatch("on_update",json!({"dt":0.1}));
assert!(m.commands.is_empty());
}
#[test]
fn network_nil_clears_and_oversized_values_fail() {
let f=Fixture::new(r#"return {on_load=function() sdk.net.publish("score",nil) end}"#);
let mut m=f.manager();m.enable("example",true).unwrap();
assert!(matches!(&m.commands[0].1,Command::NetworkState{value,..} if value.is_null()));
let f=Fixture::new(r#"return {on_load=function() sdk.net.publish("score",string.rep("x",513)) end}"#);
let mut m=f.manager();m.enable("example",true).unwrap();assert!(m.packages["example"].error.is_some());assert!(m.commands.is_empty());
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! Bounded control messages between the game and the optional discovery helper.
use serde::{Deserialize, Serialize};
pub const NAMESPACE: &str = "skate3rust-free-skate-v4";
pub const NAMESPACE: &str = "skate3rust-free-skate-v5";
pub const PAGE_SIZE: usize = 5;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
+76 -1
View File
@@ -10,6 +10,13 @@ pub const PING: u8 = 6;
pub const PONG: u8 = 7;
pub const REJECT: u8 = 8;
pub const GOODBYE: u8 = 9;
pub const APPLICATION: u8 = 10;
pub const APPLICATION_ACK: u8 = 11;
pub const MAX_APP_KEYS: usize = 256;
pub const MAX_APP_VALUE: usize = 1024;
#[derive(Clone, Debug)]
pub struct Application { pub seq: u32, pub value: Vec<u8>, pub received: u64 }
pub const DEFAULT_LINK_BUDGET: f64 = 180_000.;
pub const DEFAULT_HOST_BUDGET: f64 = 1_000_000.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -64,6 +71,7 @@ pub struct Actor {
pub info: Info,
pub body: Stream,
pub pose: Stream,
pub application: BTreeMap<String, Application>,
}
impl Actor {
fn new(info: Info) -> Self {
@@ -71,6 +79,7 @@ impl Actor {
info,
body: Stream::default(),
pose: Stream::default(),
application: BTreeMap::new(),
}
}
pub fn stream(&self, kind: u8) -> &Stream {
@@ -91,6 +100,9 @@ struct Link {
sent: BTreeMap<(u64, u8), (u64, u32)>,
keys: BTreeMap<(u64, u8), u64>,
credits: f64,
app_sent: BTreeMap<(u64, String), (u64, u32)>,
app_acks: BTreeMap<(u64,String),u32>,
app_round: usize,
last_ack: u64,
last_ping: u64,
rtt: u64,
@@ -104,6 +116,7 @@ impl Link {
sent: BTreeMap::new(),
keys: BTreeMap::new(),
credits: 2400.,
app_sent: BTreeMap::new(), app_acks: BTreeMap::new(), app_round: 0,
last_ack: 0,
last_ping: 0,
rtt: 0,
@@ -230,6 +243,17 @@ impl Session {
});
stream.push(seq, state, now);
}
/// Latest owner-authenticated state. Empty values are persistent tombstones.
/// Repeated full records recover loss and initialize late joiners without replaying Lua.
pub fn publish_application(&mut self, key: &str, value: Vec<u8>, now: u64) -> bool {
if key.is_empty() || key.len() > 128 || value.len() > MAX_APP_VALUE { return false; }
let records = &mut self.actors.get_mut(&self.local).unwrap().application;
if records.len() >= MAX_APP_KEYS && !records.contains_key(key) { return false; }
if records.get(key).is_some_and(|r| r.value == value) { return true; }
let seq = records.get(key).map_or(1, |r| r.seq.saturating_add(1));
records.insert(key.into(), Application {seq, value, received: now});
true
}
pub fn record_send(&mut self, len: usize, success: bool) {
if success {
self.stats.tx_bytes += len as u64;
@@ -355,7 +379,33 @@ impl Session {
.into();
return;
}
if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE) {
if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE | APPLICATION) {
return;
}
if kind == APPLICATION_ACK {
let Some(origin)=r.u64() else {return;};
let Ok(key)=std::str::from_utf8(r.0) else {return;};
if self.actors.get(&origin).and_then(|a|a.application.get(key)).is_some_and(|record|seq<=record.seq) {
let link=self.links.get_mut(&peer).unwrap();
let ack=link.app_acks.entry((origin,key.into())).or_default();*ack=(*ack).max(seq);link.seen=now;
}
return;
}
if kind == APPLICATION {
if actor == self.local || (self.is_host() && self.links[&peer].actor != actor) { return; }
let Some(a) = self.actors.get_mut(&actor) else { return; };
let Some(length) = r.byte() else { return; };
let length = length as usize;
if length == 0 || length > 128 || r.0.len() < length || r.0.len() - length > MAX_APP_VALUE { return; }
let Ok(key) = std::str::from_utf8(&r.0[..length]) else { return; };
if a.application.len() >= MAX_APP_KEYS && !a.application.contains_key(key) { return; }
if a.application.get(key).is_none_or(|old| seq > old.seq) {
a.application.insert(key.into(), Application { seq, value: r.0[length..].to_vec(), received: now });
}
let latest=a.application.get(key).map_or(seq,|r|r.seq);
let mut ack=packed::header(self.session,self.local,APPLICATION_ACK,latest);
ack.extend(actor.to_le_bytes());ack.extend(key.as_bytes());self.queue(peer,ack);
self.links.get_mut(&peer).unwrap().seen = now;
return;
}
if kind == GOODBYE {
@@ -622,6 +672,31 @@ impl Session {
}
}
}
// Rotate every record, not just actors, so a busy mod cannot starve later keys.
let mut app_peers:Vec<_>=self.links.keys().copied().collect();
if !app_peers.is_empty() {let offset=self.round%app_peers.len();app_peers.rotate_left(offset);}
for peer in app_peers {
let link=self.links.get_mut(&peer).unwrap();
link.app_sent.retain(|(id, _), _| self.actors.contains_key(id));
link.app_acks.retain(|(id,_),_|self.actors.contains_key(id));
let records: Vec<_> = self.actors.iter().filter(|(id, _)| **id != link.actor && (self.host.is_none() || **id == self.local))
.flat_map(|(&id, a)| a.application.iter().map(move |(key, record)| (id, key, record))).collect();
let count = records.len();
for offset in 0..count {
let index = (link.app_round + offset) % count;
let (id, key, record) = records[index];
if link.app_acks.get(&(id,key.clone())).is_some_and(|&seq|seq==record.seq) {continue;}
let previous = link.app_sent.get(&(id, key.clone()));
if previous.is_some_and(|&(at, seq)| now.saturating_sub(at) < if seq == record.seq { 110 + ((self.round as u64 + id) * 17) % 130 } else { 50 }) { continue; }
let mut data = packed::header(self.session, id, APPLICATION, record.seq);
data.push(key.len() as u8); data.extend(key.as_bytes()); data.extend(&record.value);
let bytes = data.len() as f64;
if bytes > link.credits || bytes > self.credits { link.app_round = index; break; }
link.credits -= bytes; self.credits -= bytes;
link.app_sent.insert((id, key.clone()), (now, record.seq));
output.push(Outgoing {peer, data});
}
}
self.stats.rtt_ms = self.links.values().map(|l| l.rtt).max().unwrap_or(0);
output
}
+4 -4
View File
@@ -1,10 +1,10 @@
//! Version 4: one datagram per independently decodable stream update.
//! Version 5: one datagram per independently decodable stream update.
//! Position error <= 0.5 mm/axis within 32 m; larger offsets use full floats.
//! Quaternion uses smallest-three 10-bit components. Velocities use binary16.
use crate::{Body, Bone, Pose};
use half::f16;
pub const MTU: usize = 1200;
pub const MAGIC: &[u8; 8] = b"SK8NET04";
pub const MAGIC: &[u8; 8] = b"SK8NET05";
pub const HEADER: usize = 29;
pub const BODY: u8 = 1;
pub const POSE: u8 = 2;
@@ -167,7 +167,7 @@ impl Packed {
self.root == other.root && self.enabled == other.enabled && self.rows == other.rows
}
pub fn body(s: &BodyState) -> Option<Self> {
if !s.root.valid() || s.bodies.len() != 33 || (s.enabled & !(1 << 63)) >> 33 != 0 {
if !s.root.valid() || s.bodies.len() != 33 || (s.enabled & !((1 << 63) | (1 << 62))) >> 33 != 0 {
return None;
}
let mut rows = Vec::with_capacity(33);
@@ -222,7 +222,7 @@ impl Packed {
pub fn unpack_body(&self) -> Option<BodyState> {
let root = unroot(self.root)?;
let mut bodies = vec![];
if self.rows.len() != 33 || (self.enabled & !(1 << 63)) >> 33 != 0 {
if self.rows.len() != 33 || (self.enabled & !((1 << 63) | (1 << 62))) >> 33 != 0 {
return None;
}
for row in &self.rows {
+119
View File
@@ -0,0 +1,119 @@
use skate_net::{
lobby::{APPLICATION, Info, MAX_APP_KEYS, Session},
packed,
};
fn info(id: u64) -> Info {
Info {
id,
map: 1,
rig: 2,
physics: 3,
appearance: 4,
}
}
fn pump(host: &mut Session, guests: &mut [Session], now: u64, loss: bool) {
for (i, g) in guests.iter_mut().enumerate() {
for (n, p) in g.service(now).into_iter().enumerate() {
if !loss || (now / 50 + n as u64) % 3 != 0 {
host.receive(i as u64 + 10, &p.data, now);
}
}
}
for (n, p) in host.service(now).into_iter().enumerate() {
assert!(p.data.len() <= packed::MTU);
if !loss || (now / 50 + n as u64) % 4 != 0 {
if let Some(g) = guests.get_mut((p.peer - 10) as usize) {
g.receive(1, &p.data, now);
}
}
}
}
#[test]
fn owner_state_recovers_loss_late_join_and_tombstones() {
let mut h = Session::new(7, info(1), None);
let mut g = vec![Session::new(7, info(2), Some(1))];
assert!(g[0].publish_application("kart", b"spawned".to_vec(), 0));
for t in (0..2000).step_by(50) {
pump(&mut h, &mut g, t, true);
}
assert_eq!(h.actors[&2].application["kart"].value, b"spawned");
g.push(Session::new(7, info(3), Some(1)));
for t in (2000..4000).step_by(50) {
pump(&mut h, &mut g, t, true);
}
assert_eq!(g[1].actors[&2].application["kart"].value, b"spawned");
g[0].publish_application("kart", vec![], 4000);
for t in (4000..6000).step_by(50) {
pump(&mut h, &mut g, t, true);
}
assert!(g[1].actors[&2].application["kart"].value.is_empty());
for p in g[0].goodbye() {
h.receive(10, &p.data, 6000);
}
// Only the second guest remains; service it without reviving the first.
for t in (6000..7000).step_by(50) {
for p in g[1].service(t) {
h.receive(11, &p.data, t);
}
for p in h.service(t) {
if p.peer == 11 {
g[1].receive(1, &p.data, t);
}
}
}
assert!(!g[1].actors.contains_key(&2));
}
fn wire(actor: u64, seq: u32, key: &str, value: &[u8]) -> Vec<u8> {
let mut b = packed::header(7, actor, APPLICATION, seq);
b.push(key.len() as u8);
b.extend(key.as_bytes());
b.extend(value);
b
}
#[test]
fn spoofed_owners_old_updates_and_oversized_records_are_rejected() {
let mut h = Session::new(7, info(1), None);
let mut g = vec![
Session::new(7, info(2), Some(1)),
Session::new(7, info(3), Some(1)),
];
for t in (0..1000).step_by(50) {
pump(&mut h, &mut g, t, false);
}
h.receive(10, &wire(3, 10, "state", b"forged"), 1000);
assert!(h.actors[&3].application.is_empty());
h.receive(10, &wire(2, 5, "state", b"new"), 1000);
h.receive(10, &wire(2, 4, "state", b"old"), 1001);
assert_eq!(h.actors[&2].application["state"].value, b"new");
h.receive(10, &wire(2, 6, "state", &vec![0; 1025]), 1002);
assert_eq!(h.actors[&2].application["state"].value, b"new");
assert!(!h.publish_application("big", vec![0; 1025], 0));
for i in 0..MAX_APP_KEYS {
assert!(h.publish_application(&format!("k{i}"), vec![1], 0));
}
assert!(!h.publish_application("overflow", vec![], 0));
assert!(h.publish_application("k0", vec![], 0));
}
#[test]
fn many_mod_records_eventually_reach_every_player() {
let mut h = Session::new(7, info(1), None);
let mut g: Vec<_> = (2..=10)
.map(|id| Session::new(7, info(id), Some(1)))
.collect();
for (i, g) in g.iter_mut().enumerate() {
for k in 0..24 {
assert!(g.publish_application(&format!("object{k}"), vec![i as u8; 900], 0));
}
}
for t in (0..30000).step_by(50) {
pump(&mut h, &mut g, t, true);
}
for guest in &g {
assert_eq!(guest.actors.len(), 10);
for (&id, a) in &guest.actors {
if id != 1 {
assert_eq!(a.application.len(), 24, "peer {} actor {id}", guest.local);
}
}
}
}
+10
View File
@@ -254,3 +254,13 @@ fn repeated_host_migration_preserves_local_state_and_rebuilds_ten_player_lobby()
}
}
}
#[test]
fn occupied_driver_flag_preserves_root_without_native_collision_parts() {
let mut frame=body();frame.enabled=1u64<<62;frame.root=pose([10.,4.,20.],0.4);
let packed=Packed::body(&frame).unwrap();
let bytes=packed::delta(1,2,packed::BODY,1,&packed,None);
let decoded=packed::apply(&bytes,None).unwrap().unpack_body().unwrap();
assert_eq!(decoded.enabled,1u64<<62);assert_eq!(decoded.root.p,frame.root.p);
frame.enabled=1u64<<61;assert!(Packed::body(&frame).is_none());
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name="skate-vehicles"
version.workspace=true
edition.workspace=true
publish.workspace=true
[dependencies]
rapier3d="=0.35.3"
serde.workspace=true
serde_json.workspace=true
+262
View File
@@ -0,0 +1,262 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct VehicleDefinition {
pub version: u32,
pub model: String,
pub model_scale: f32,
pub model_offset: [f32; 3],
pub model_yaw: f32,
pub half_extents: [f32; 3],
pub center_of_mass: [f32; 3],
pub inertia_half_extents: Option<[f32; 3]>,
pub collider_offset: [f32; 3],
pub collider_rounding: f32,
pub chassis_friction: f32,
pub engine_audio: EngineAudio,
pub rider_safety: RiderSafety,
pub mass: f32,
pub engine_force: f32,
pub brake_impulse: f32,
pub max_speed: f32,
pub steering_angle: f32,
pub suspension_length: f32,
pub suspension_stiffness: f32,
pub suspension_damping: f32,
pub tire_grip: f32,
pub wheels: Vec<WheelDefinition>,
pub seat: [f32; 3],
pub exit: [f32; 3],
pub camera_distance: f32,
pub camera_height: f32,
pub animations: Animations,
}
/// Occupied-seat collision and automatic ejection. Coordinates are chassis-local.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct RiderSafety {
pub enabled: bool,
pub offset: [f32; 3],
pub radius: f32,
pub half_height: f32,
pub crash_delta_v: f32,
pub hit_impulse: f32,
pub inverted_up_y: f32,
pub inverted_seconds: f32,
pub eject_up_speed: f32,
}
impl Default for RiderSafety {
fn default() -> Self { Self { enabled: true, offset: [0.,0.5,0.], radius: 0.25,
half_height: 0.25, crash_delta_v: 6., hit_impulse: 180., inverted_up_y: -0.2,
inverted_seconds: 0.2, eject_up_speed: 2. } }
}
/// Built-in synthesized engine; no external recording is required.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EngineAudio {
pub enabled: bool,
pub volume: f32,
pub idle_pitch: f32,
pub max_pitch: f32,
}
impl Default for EngineAudio {
fn default() -> Self { Self { enabled: false, volume: 0.45, idle_pitch: 0.7, max_pitch: 2.8 } }
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WheelDefinition {
pub position: [f32; 3],
pub radius: f32,
pub steering: bool,
pub driven: bool,
#[serde(default)]
pub node: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Animations {
pub file: Option<String>,
pub enter: Option<String>,
pub exit: Option<String>,
pub drive: Option<String>,
pub idle: Option<String>,
pub reverse: Option<String>,
pub brake: Option<String>,
pub steer_left: Option<String>,
pub steer_right: Option<String>,
}
impl Default for VehicleDefinition {
fn default() -> Self {
Self {
version: 1,
model: String::new(),
model_scale: 1.,
model_offset: [0.; 3],
model_yaw: 0.,
half_extents: [0.7, 0.2, 1.1],
center_of_mass: [0.; 3],
inertia_half_extents: None,
collider_offset: [0.; 3],
collider_rounding: 0.,
chassis_friction: 0.3,
engine_audio: EngineAudio::default(),
rider_safety: RiderSafety::default(),
mass: 220.,
engine_force: 1800.,
brake_impulse: 100.,
max_speed: 25.,
steering_angle: 0.5,
suspension_length: 0.25,
suspension_stiffness: 30.,
suspension_damping: 4.,
tire_grip: 3.,
wheels: vec![],
seat: [0., 0.25, 0.],
exit: [1.8, 0., 0.],
camera_distance: 5.,
camera_height: 2.,
animations: Animations::default(),
}
}
}
pub fn package_path(path: &str) -> bool {
!path.is_empty()
&& path.len() <= 256
&& !path.contains([':', '\\', '#'])
&& path
.split('/')
.all(|p| !p.is_empty() && p != "." && p != "..")
&& !path.chars().any(char::is_control)
}
pub fn point(p: &[f32; 3], max: f32) -> bool {
p.iter().all(|x| x.is_finite() && x.abs() <= max)
}
impl VehicleDefinition {
pub fn validate(&self) -> Result<(), String> {
let range = |v: f32, a: f32, b: f32| v.is_finite() && (a..=b).contains(&v);
if self.version != 1 || !package_path(&self.model) || !self.model.ends_with(".glb") {
return Err("Vehicle version must be 1 and model a package-relative GLB".into());
}
if !range(self.model_scale, 0.001, 100.)
|| !point(&self.model_offset, 100.)
|| !self.model_yaw.is_finite()
|| !self.half_extents.iter().all(|x| range(*x, 0.05, 10.))
|| !point(&self.center_of_mass, 10.)
|| self.inertia_half_extents.is_some_and(|v| !v.iter().all(|&x| range(x, 0.05, 10.)))
|| !point(&self.collider_offset, 10.)
|| !range(self.collider_rounding, 0., self.half_extents.iter().copied().fold(f32::INFINITY, f32::min) * 0.95)
|| !range(self.chassis_friction, 0., 2.)
|| !range(self.engine_audio.volume, 0., 1.)
|| !range(self.engine_audio.idle_pitch, 0.25, 2.)
|| !range(self.engine_audio.max_pitch, self.engine_audio.idle_pitch, 5.)
|| !point(&self.rider_safety.offset, 5.)
|| !range(self.rider_safety.radius, 0.1, 1.)
|| !range(self.rider_safety.half_height, 0.05, 1.)
|| !range(self.rider_safety.crash_delta_v, 1., 50.)
|| !range(self.rider_safety.hit_impulse, 10., 10000.)
|| !range(self.rider_safety.inverted_up_y, -1., 0.5)
|| !range(self.rider_safety.inverted_seconds, 0.05, 3.)
|| !range(self.rider_safety.eject_up_speed, 0., 10.)
|| !range(self.mass, 10., 10000.)
|| !range(self.engine_force, 0., 100000.)
|| !range(self.brake_impulse, 0., 10000.)
|| !range(self.max_speed, 1., 100.)
|| !range(self.steering_angle, 0.01, 1.2)
|| !range(self.suspension_length, 0.01, 2.)
|| !range(self.suspension_stiffness, 1., 200.)
|| !range(self.suspension_damping, 0.1, 30.)
|| !range(self.tire_grip, 0.1, 20.)
|| !point(&self.seat, 10.)
|| !point(&self.exit, 10.)
|| !range(self.camera_distance, 2., 30.)
|| !range(self.camera_height, 0.5, 15.)
{
return Err("Vehicle dimensions/tuning outside supported finite ranges".into());
}
if !(2..=8).contains(&self.wheels.len())
|| !self.wheels.iter().any(|w| w.driven)
|| !self.wheels.iter().all(|w| {
point(&w.position, 10.)
&& range(w.radius, 0.05, 2.)
&& w.node
.as_ref()
.is_none_or(|n| !n.is_empty() && n.len() <= 128)
})
{
return Err("Vehicle needs 2..8 valid wheels and at least one driven wheel".into());
}
if self
.animations
.file
.as_ref()
.is_some_and(|p| !package_path(p))
{
return Err("Invalid vehicle animation path".into());
}
for name in [
&self.animations.enter,
&self.animations.exit,
&self.animations.drive,
&self.animations.idle,
&self.animations.reverse,
&self.animations.brake,
&self.animations.steer_left,
&self.animations.steer_right,
]
.into_iter()
.flatten()
{
if self.animations.file.is_none() || name.is_empty() || name.len() > 128 {
return Err("Animation slots require a file and valid clip names".into());
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct VehicleTuning {
pub engine_volume: Option<f32>,
pub engine_force: Option<f32>,
pub max_speed: Option<f32>,
pub brake_impulse: Option<f32>,
pub steering_angle: Option<f32>,
pub tire_grip: Option<f32>,
}
impl VehicleTuning {
pub fn apply(&self, definition: &VehicleDefinition) -> Result<VehicleDefinition, String> {
let mut d = definition.clone();
if let Some(v) = self.engine_volume { d.engine_audio.volume = v; }
if let Some(v) = self.engine_force {
d.engine_force = v;
}
if let Some(v) = self.max_speed {
d.max_speed = v;
}
if let Some(v) = self.brake_impulse {
d.brake_impulse = v;
}
if let Some(v) = self.steering_angle {
d.steering_angle = v;
}
if let Some(v) = self.tire_grip {
d.tire_grip = v;
}
d.validate()?;
Ok(d)
}
pub fn valid(&self) -> bool {
[
self.engine_volume.map(|x| (x, 0., 1.)),
self.engine_force.map(|x| (x, 0., 100000.)),
self.max_speed.map(|x| (x, 1., 100.)),
self.brake_impulse.map(|x| (x, 0., 10000.)),
self.steering_angle.map(|x| (x, 0.01, 1.2)),
self.tire_grip.map(|x| (x, 0.1, 20.)),
]
.into_iter()
.flatten()
.all(|(x, a, b)| x.is_finite() && (a..=b).contains(&x))
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Bevy-independent, fixed-step Rapier vehicle simulation and validated mod definitions.
mod definition;
mod safety;
pub use safety::Ejection;
pub use definition::*;
pub use rapier3d;
use rapier3d::{
control::{DynamicRayCastVehicleController, WheelTuning},
prelude::*,
};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Controls {
pub throttle: f32,
pub steering: f32,
pub brake: f32,
pub handbrake: bool,
}
impl Controls {
pub fn valid(&self) -> bool {
self.throttle.is_finite()
&& self.steering.is_finite()
&& self.brake.is_finite()
&& (-1. ..=1.).contains(&self.throttle)
&& (-1. ..=1.).contains(&self.steering)
&& (0. ..=1.).contains(&self.brake)
}
}
pub struct Vehicle {
pub definition: VehicleDefinition,
pub body: RigidBodyHandle,
pub controller: DynamicRayCastVehicleController,
pub controls: Controls,
rider: ColliderHandle,
occupied: bool,
pub remote: bool,
inverted_time: f32,
pub ejection: Option<Ejection>,
}
pub struct Simulation {
pub world: PhysicsWorld,
pub vehicles: BTreeMap<u64, Vehicle>,
next: u64,
}
impl Default for Simulation {
fn default() -> Self {
Self {
world: PhysicsWorld::default(),
vehicles: BTreeMap::new(),
next: 1,
}
}
}
impl Simulation {
pub fn ground(&mut self, triangles: impl Iterator<Item = [[f32; 3]; 3]>) -> Result<(), String> {
let mut vertices = Vec::new();
let mut indices = Vec::new();
for triangle in triangles {
let n = vertices.len() as u32;
vertices.extend(triangle.map(Vector::from_array));
indices.push([n, n + 1, n + 2]);
}
if vertices.is_empty() {
return Err("Vehicle collision world has no triangles".into());
}
let collider = ColliderBuilder::trimesh(vertices, indices)
.map_err(|e| e.to_string())?
.friction(1.);
self.world.insert(RigidBodyBuilder::fixed(), collider);
self.world.step();
Ok(())
}
pub fn spawn(
&mut self,
definition: VehicleDefinition,
position: [f32; 3],
heading: f32,
) -> Result<u64, String> {
definition.validate()?;
if !point(&position, 100000.) || !heading.is_finite() {
return Err("Invalid vehicle spawn pose".into());
}
let d = &definition;
let r = d.collider_rounding;
let shape = if r > 0. {
ColliderBuilder::round_cuboid(d.half_extents[0]-r, d.half_extents[1]-r, d.half_extents[2]-r, r)
} else {
ColliderBuilder::cuboid(d.half_extents[0], d.half_extents[1], d.half_extents[2])
};
let [x, y, z] = d.inertia_half_extents.unwrap_or(d.half_extents);
let inertia = Vector::new(y*y+z*z, x*x+z*z, x*x+y*y) * (d.mass / 3.);
let mass_properties = MassProperties::new(Vector::from_array(d.center_of_mass) - Vector::from_array(d.collider_offset), d.mass, inertia);
let (body, _) = self.world.insert(
RigidBodyBuilder::dynamic()
.translation(Vector::from_array(position))
.rotation(Vector::Y * heading)
.ccd_enabled(true)
.linear_damping(0.08)
.angular_damping(0.5),
shape.translation(Vector::from_array(d.collider_offset))
.mass_properties(mass_properties)
.friction(d.chassis_friction),
);
let mut controller = DynamicRayCastVehicleController::new(body);
controller.index_up_axis = 1;
controller.index_forward_axis = 2;
let tuning = WheelTuning {
suspension_stiffness: d.suspension_stiffness,
suspension_compression: d.suspension_damping,
suspension_damping: d.suspension_damping,
max_suspension_travel: d.suspension_length,
friction_slip: d.tire_grip,
..Default::default()
};
for w in &d.wheels {
controller.add_wheel(
Vector::from_array(w.position),
-Vector::Y,
-Vector::X,
d.suspension_length,
w.radius,
&tuning,
);
}
let safety = &d.rider_safety;
let rider = self.world.colliders.insert_with_parent(
ColliderBuilder::capsule_y(safety.half_height, safety.radius)
.translation(Vector::from_array(d.seat) + Vector::from_array(safety.offset))
.density(0.).friction(0.2).enabled(false).build(), body, &mut self.world.bodies);
let id = self.next;
self.next += 1;
self.vehicles.insert(
id,
Vehicle {
definition,
body,
controller,
controls: Controls::default(),
rider, remote: false, occupied: false, inverted_time: 0., ejection: None,
},
);
Ok(id)
}
pub fn remove(&mut self, id: u64) {
if let Some(v) = self.vehicles.remove(&id) {
self.world.remove_body(v.body);
}
}
pub fn step(&mut self, dt: f32) {
if !dt.is_finite() || dt <= 0. {
return;
}
// Bound substeps even when a host uses a coarse fixed timestep.
let steps = (dt / 0.008334).ceil().clamp(1., 16.) as u32;
let h = dt.min(0.1) / steps as f32;
for _ in 0..steps {
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;
let driven = d.wheels.iter().filter(|w| w.driven).count() as f32;
for (wheel, def) in v.controller.wheels_mut().iter_mut().zip(&d.wheels) {
wheel.steering = if def.steering {
// With +Z forward and +Y up, the driver's left is +X.
c.steering * d.steering_angle / (1. + speed.abs() * 0.025)
} else {
0.
};
wheel.engine_force =
if def.driven && (speed.abs() < d.max_speed || c.throttle * speed < 0.) {
c.throttle * d.engine_force / driven
} else {
0.
};
wheel.brake = if c.handbrake && !def.steering {
d.brake_impulse
} else {
c.brake * d.brake_impulse
};
}
let queries = self.world.broad_phase.as_query_pipeline_mut(
self.world.narrow_phase.query_dispatcher(),
&mut self.world.bodies,
&mut self.world.colliders,
QueryFilter::default().exclude_rigid_body(v.body),
);
v.controller.update_vehicle(h, queries);
}
self.world.step();
self.check_riders(&before, h);
}
}
pub fn pose(&self, id: u64) -> Option<([f32; 3], [f32; 4])> {
let body = &self.world.bodies[self.vehicles.get(&id)?.body];
Some((body.translation().to_array(), body.rotation().to_array()))
}
pub fn reset(&mut self, id: u64, position: [f32; 3], heading: f32) -> Result<(), String> {
if !point(&position, 100000.) || !heading.is_finite() {
return Err("Invalid reset pose".into());
}
let v = self.vehicles.get_mut(&id).ok_or("Unknown vehicle")?;
let b = &mut self.world.bodies[v.body];
b.set_translation(Vector::from_array(position), true);
b.set_rotation(Rotation::from_rotation_y(heading), true);
b.set_linvel(Vector::ZERO, true);
b.set_angvel(Vector::ZERO, true);
v.controls = Controls::default();
v.ejection = None; v.inverted_time = 0.;
Ok(())
}
pub fn floor(&self, position: [f32; 3]) -> Option<[f32; 3]> {
let origin = Vector::from_array(position) + Vector::Y * 2.;
let (_, hit) = self.world.cast_ray(
&Ray::new(origin, -Vector::Y),
12.,
true,
QueryFilter::only_fixed(),
)?;
let floor = origin - Vector::Y * hit;
let shape = rapier3d::parry::shape::Capsule::new_y(0.55, 0.3);
if self
.world
.intersect_shape(
Pose::from_translation(floor + Vector::Y * 1.),
&shape,
QueryFilter::default(),
)
.next()
.is_some()
{
return None;
}
Some(floor.to_array())
}
}
+58
View File
@@ -0,0 +1,58 @@
use super::*;
#[derive(Clone, Copy, Debug)]
pub struct Ejection {
pub position: [f32; 3],
pub velocity: [f32; 3],
pub angular_velocity: [f32; 3],
pub reason: &'static str,
}
impl Simulation {
pub fn set_occupied(&mut self, id: u64, occupied: bool) {
if let Some(v) = self.vehicles.get_mut(&id) {
if v.occupied != occupied {
v.occupied = occupied; v.inverted_time = 0.; v.ejection = None;
self.world.colliders[v.rider].set_enabled(occupied && v.definition.rider_safety.enabled);
self.world.bodies[v.body].wake_up(true);
}
}
}
pub fn take_ejection(&mut self, id: u64) -> Option<Ejection> {
self.vehicles.get_mut(&id)?.ejection.take()
}
pub(super) fn capture_riders(&self) -> BTreeMap<u64, (Vector, Vector)> {
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));
(id, (body.linvel(), body.velocity_at_point(point)))
}).collect()
}
pub(super) fn check_riders(&mut self, before: &BTreeMap<u64,(Vector,Vector)>, dt: f32) {
for (&id, &(linear, point_velocity)) in before {
let v=self.vehicles.get_mut(&id).unwrap();
if v.ejection.is_some() { continue; }
let body=&self.world.bodies[v.body];
let safety=&v.definition.rider_safety;
let up=body.rotation()*Vector::Y;
v.inverted_time=if up.y < safety.inverted_up_y {v.inverted_time+dt} else {0.};
let hit=self.world.narrow_phase.contact_pairs_with(v.rider)
.map(|p|p.total_impulse_magnitude()).fold(0_f32,f32::max);
let contact=body.colliders().iter().any(|&c| self.world.narrow_phase.contact_pairs_with(c)
.any(|p|p.total_impulse_magnitude()>0.));
let reason=if hit >= safety.hit_impulse {Some("rider_impact")}
else if contact && (body.linvel()-linear).length() >= safety.crash_delta_v {Some("crash")}
else if v.inverted_time >= safety.inverted_seconds {Some("inverted")}
else {None};
if let Some(reason)=reason {
let seat=body.position().transform_point(Vector::from_array(v.definition.seat));
let after=body.velocity_at_point(seat);
let velocity=if after.length_squared()>point_velocity.length_squared() {after} else {point_velocity};
let velocity=(velocity + Vector::Y*safety.eject_up_speed).clamp_length_max(60.);
v.ejection=Some(Ejection { position: seat.to_array(), velocity: velocity.to_array(),
angular_velocity: body.angvel().clamp_length_max(15.).to_array(), reason });
}
}
}
}
+274
View File
@@ -0,0 +1,274 @@
use skate_vehicles::*;
fn definition() -> VehicleDefinition {
serde_json::from_str(include_str!("../../../sdk/examples/mario-kart/vehicle.json")).unwrap()
}
fn simulation() -> Simulation {
let mut s = Simulation::default();
s.ground(
[
[[-100., 0., -100.], [100., 0., 100.], [100., 0., -100.]],
[[-100., 0., -100.], [-100., 0., 100.], [100., 0., 100.]],
]
.into_iter(),
)
.unwrap();
s
}
#[test]
fn kart_suspension_acceleration_braking_reset_and_cleanup() {
let mut s = simulation();
let id = s.spawn(definition(), [0., 2., 0.], 0.).unwrap();
for _ in 0..240 {
s.step(1. / 120.);
}
let height = s.pose(id).unwrap().0[1];
assert!((0.2..1.).contains(&height), "settled chassis {height}");
assert!(
s.vehicles[&id]
.controller
.wheels()
.iter()
.all(|w| w.raycast_info().is_in_contact)
);
s.vehicles.get_mut(&id).unwrap().controls = Controls {
throttle: 1.,
..Default::default()
};
for _ in 0..360 {
s.step(1. / 120.);
}
let speed = s.vehicles[&id].controller.current_vehicle_speed;
assert!(speed > 3., "forward speed {speed}");
assert!(s.pose(id).unwrap().0[2] > 3.);
s.vehicles.get_mut(&id).unwrap().controls = Controls {
brake: 1.,
..Default::default()
};
for _ in 0..240 {
s.step(1. / 120.);
}
assert!(s.vehicles[&id].controller.current_vehicle_speed.abs() < speed * 0.25, "brake speed {} initial {speed}, pose {:?}", s.vehicles[&id].controller.current_vehicle_speed, s.pose(id));
s.reset(id, [5., 2., 5.], 1.).unwrap();
assert_eq!(s.pose(id).unwrap().0, [5., 2., 5.]);
s.remove(id);
assert!(s.vehicles.is_empty());
assert_eq!(s.world.bodies.len(), 1);
}
#[test]
fn definitions_and_controls_reject_invalid_inputs() {
let mut d = definition();
d.mass = f32::NAN;
assert!(d.validate().is_err());
let mut d = definition();
d.model = "../outside.glb".into();
assert!(d.validate().is_err());
let mut d = definition();
d.animations.file = None;
d.animations.drive = Some("drive".into());
assert!(d.validate().is_err());
let mut d = definition();
d.wheels.clear();
assert!(d.validate().is_err());
assert!(
!Controls {
throttle: 2.,
..Default::default()
}
.valid()
);
assert!(
!Controls {
brake: f32::NAN,
..Default::default()
}
.valid()
);
assert!(!package_path("C:/model.glb"));
assert!(!package_path("../model.glb"));
assert!(!package_path("model.glb#Scene1"));
assert!(
VehicleTuning {
max_speed: Some(-1.),
..Default::default()
}
.apply(&definition())
.is_err()
);
}
#[test]
fn independent_vehicles_collide_and_remain_finite() {
let mut s = simulation();
let a = s.spawn(definition(), [0., 1., -4.], 0.).unwrap();
let b = s
.spawn(definition(), [0., 1., 4.], std::f32::consts::PI)
.unwrap();
for id in [a, b] {
s.vehicles.get_mut(&id).unwrap().controls = Controls {
throttle: 1.,
..Default::default()
};
}
for _ in 0..400 {
s.step(1. / 120.);
}
for id in [a, b] {
let (p, q) = s.pose(id).unwrap();
assert!(p.iter().chain(q.iter()).all(|x| x.is_finite()));
}
}
#[test]
fn steering_turns_toward_the_drivers_requested_side() {
for heading in [0., std::f32::consts::FRAC_PI_2] {
for steering in [-0.5, 0.5] {
let mut s = simulation();
let id = s.spawn(definition(), [0., 1., 0.], heading).unwrap();
for _ in 0..120 {
s.step(1. / 120.);
}
s.vehicles.get_mut(&id).unwrap().controls = Controls {
throttle: 1.,
steering,
..Default::default()
};
for _ in 0..180 {
s.step(1. / 120.);
}
// Driver/camera left is up cross forward: +X at heading zero (+Z forward).
let p = s.pose(id).unwrap().0;
let left_displacement = p[0] * heading.cos() - p[2] * heading.sin();
assert!(
left_displacement * steering > 0.1,
"heading={heading}, steering={steering}, left displacement={left_displacement}"
);
}
}
}
#[test]
fn kart_climbs_ramps_without_nose_catching() {
for degrees in [20_f32, 30., 40.] {
let slope = degrees.to_radians().tan();
let mut s = Simulation::default();
let y = 20. * slope;
s.ground([
[[-20.,0.,-30.],[20.,0.,3.],[20.,0.,-30.]],
[[-20.,0.,-30.],[-20.,0.,3.],[20.,0.,3.]],
[[-20.,0.,3.],[20.,y,23.],[20.,0.,3.]],
[[-20.,0.,3.],[-20.,y,23.],[20.,y,23.]],
].into_iter()).unwrap();
let id = s.spawn(definition(), [0.,1.,-2.], 0.).unwrap();
for _ in 0..240 { s.step(1./120.); }
s.vehicles.get_mut(&id).unwrap().controls = Controls { throttle: 1., ..Default::default() };
let mut highest = 0_f32;
let mut forward = 0_f32;
for _ in 0..960 {
s.step(1./120.);
let p=s.pose(id).unwrap().0;
highest=highest.max(p[1]); forward=forward.max(p[2]);
}
println!("Ramp {degrees}: height={highest}, forward={forward}");
assert!(highest > 2. && forward > 7., "failed {degrees} degree ramp: {highest}, {forward}");
}
}
#[test]
fn collision_mass_and_audio_tuning_are_validated() {
let base=definition();
let mut d=base.clone(); d.collider_rounding=0.5; assert!(d.validate().is_err());
let mut d=base.clone(); d.center_of_mass[1]=f32::NAN; assert!(d.validate().is_err());
let mut d=base.clone(); d.inertia_half_extents=Some([0.,1.,1.]); assert!(d.validate().is_err());
let mut d=base.clone(); d.engine_audio.max_pitch=0.1; assert!(d.validate().is_err());
let mute=VehicleTuning { engine_volume:Some(0.), ..Default::default() };
assert!(mute.valid()); assert_eq!(mute.apply(&base).unwrap().engine_audio.volume,0.);
let invalid=VehicleTuning { engine_volume:Some(f32::NAN), ..Default::default() };
assert!(!invalid.valid()); assert!(invalid.apply(&base).is_err());
}
#[test]
fn occupied_crash_ejects_with_preimpact_momentum() {
use rapier3d::prelude::*;
let mut s=simulation();
s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.,1.,3.)),ColliderBuilder::cuboid(5.,2.,0.2));
let id=s.spawn(definition(),[0.,0.6,0.],0.).unwrap();
s.set_occupied(id,true);
let handle=s.vehicles[&id].body;
s.world.bodies[handle].set_linvel(Vector::new(0.,0.,20.),true);
let mut crash=None;
for _ in 0..120 {s.step(1./120.);if let Some(e)=s.take_ejection(id){crash=Some(e);break;}}
let crash=crash.expect("wall impact should eject");
assert!(matches!(crash.reason,"crash"|"rider_impact"));
assert!(crash.velocity[2]>15.,"momentum was lost: {:?}",crash.velocity);
s.set_occupied(id,false);
for _ in 0..60 {s.step(1./120.);}
assert!(s.take_ejection(id).is_none());
}
#[test]
fn inverted_rider_bails_but_parked_vehicle_does_not() {
use rapier3d::prelude::*;
for occupied in [false,true] {
let mut s=simulation();let id=s.spawn(definition(),[0.,4.,0.],0.).unwrap();
s.set_occupied(id,occupied);
let h=s.vehicles[&id].body;
s.world.bodies[h].set_rotation(Rotation::from_rotation_z(std::f32::consts::PI),true);
s.world.bodies[h].set_linvel(Vector::new(5.,0.,0.),true);
for _ in 0..30 {s.step(1./120.);}
let e=s.take_ejection(id);
if occupied {let e=e.unwrap();assert_eq!(e.reason,"inverted");assert!(e.velocity[0]>4.);}
else {assert!(e.is_none());}
}
}
#[test]
fn rider_hitbox_catches_an_overhang_above_the_chassis() {
use rapier3d::prelude::*;
let mut s=simulation();
s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.,1.55,3.)),ColliderBuilder::cuboid(5.,0.15,0.2));
let id=s.spawn(definition(),[0.,0.6,0.],0.).unwrap();s.set_occupied(id,true);
let h=s.vehicles[&id].body;s.world.bodies[h].set_linvel(Vector::new(0.,0.,18.),true);
let mut crash=None;
for _ in 0..120 {s.step(1./120.);if let Some(e)=s.take_ejection(id){crash=Some(e);break;}}
assert_eq!(crash.expect("head/torso collision must eject").reason,"rider_impact");
}
#[test]
fn occupied_ramp_driving_does_not_false_bail() {
let mut s=Simulation::default();let slope=30_f32.to_radians().tan();
s.ground([
[[-20.,0.,-20.],[20.,0.,3.],[20.,0.,-20.]],
[[-20.,0.,-20.],[-20.,0.,3.],[20.,0.,3.]],
[[-20.,0.,3.],[20.,20.*slope,23.],[20.,0.,3.]],
[[-20.,0.,3.],[-20.,20.*slope,23.],[20.,20.*slope,23.]],
].into_iter()).unwrap();
let id=s.spawn(definition(),[0.,0.6,-2.],0.).unwrap();s.set_occupied(id,true);
s.vehicles.get_mut(&id).unwrap().controls=Controls{throttle:1.,..Default::default()};
let mut climbed=false;
for _ in 0..960 {
s.step(1./120.);assert!(s.take_ejection(id).is_none(),"normal ramp triggered ejection");
if s.pose(id).unwrap().0[2]>10. {climbed=true;break;}
}
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");
}