From 772257cc9c7defbf77b9aebe07c905660797c094 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 18:28:56 +0100 Subject: [PATCH 01/27] Add isolated Lua mod runtime, settings menu and owned engine commands --- Cargo.lock | 101 +++- Cargo.toml | 2 +- crates/skate-game/Cargo.toml | 1 + crates/skate-game/src/animation_pose.rs | 24 +- .../src/animation_pose/authored_clips.rs | 4 +- crates/skate-game/src/app.rs | 2 +- crates/skate-game/src/graphics_menu.rs | 16 +- crates/skate-game/src/main.rs | 1 + crates/skate-game/src/modding/menu.rs | 339 +++++++++++++ crates/skate-game/src/modding/mod.rs | 460 ++++++++++++++++++ crates/skate-game/src/physics.rs | 5 + crates/skate-mods/Cargo.toml | 10 + crates/skate-mods/src/api.lua | 47 ++ crates/skate-mods/src/lib.rs | 451 +++++++++++++++++ crates/skate-mods/src/schema.rs | 134 +++++ crates/skate-mods/src/vm.rs | 224 +++++++++ crates/skate-mods/tests/runtime.rs | 273 +++++++++++ mods/route-trainer/main.lua | 45 ++ mods/route-trainer/mod.json | 11 + mods/speedometer/main.lua | 16 + mods/speedometer/mod.json | 14 + 21 files changed, 2168 insertions(+), 12 deletions(-) create mode 100644 crates/skate-game/src/modding/menu.rs create mode 100644 crates/skate-game/src/modding/mod.rs create mode 100644 crates/skate-mods/Cargo.toml create mode 100644 crates/skate-mods/src/api.lua create mode 100644 crates/skate-mods/src/lib.rs create mode 100644 crates/skate-mods/src/schema.rs create mode 100644 crates/skate-mods/src/vm.rs create mode 100644 crates/skate-mods/tests/runtime.rs create mode 100644 mods/route-trainer/main.lua create mode 100644 mods/route-trainer/mod.json create mode 100644 mods/speedometer/main.lua create mode 100644 mods/speedometer/mod.json diff --git a/Cargo.lock b/Cargo.lock index 20f03a22..58fa0cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1739,6 +1739,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -3150,6 +3160,25 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lua-src" +version = "551.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d400ffef0e3d4d29287092bdc5a276d0bf468b2c69709d5bab0d469312d9f947" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.7.3+1ee778a" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869665372263eb337b14f480cfb864b89f12eade4eb42cb415f71517b4a67572" +dependencies = [ + "cc", + "which", +] + [[package]] name = "mach2" version = "0.4.3" @@ -3233,6 +3262,38 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mlua" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96e5d00f19d8c46c71ceaced99593b90c31c57aa1fe2cb3e93a8b1698eedba9" +dependencies = [ + "bstr", + "either", + "erased-serde", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash 2.1.3", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b806d7ade031f5d6607eae3e283fb034cb795a76247dd0d1ba753c8c42debccf" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3728,6 +3789,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "5.5.0" @@ -4282,6 +4352,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -4383,6 +4463,16 @@ dependencies = [ "serde_json", "skate-core", "skate-data", + "skate-mods", +] + +[[package]] +name = "skate-mods" +version = "0.1.0" +dependencies = [ + "mlua", + "serde", + "serde_json", ] [[package]] @@ -5242,7 +5332,7 @@ dependencies = [ "ndk-sys 0.6.0+11769913", "objc", "once_cell", - "ordered-float", + "ordered-float 5.5.0", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -5274,6 +5364,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 36cacc18..0b474ee6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/skate-core", "crates/skate-data", "crates/skate-game"] +members = ["crates/skate-core", "crates/skate-data", "crates/skate-game", "crates/skate-mods"] resolver = "3" [workspace.package] diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index 60e4b20e..6358b763 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -19,3 +19,4 @@ skate-data.workspace = true half = "=2.7.1" serde_json.workspace = true serde.workspace = true +skate-mods = { path = "../skate-mods" } diff --git a/crates/skate-game/src/animation_pose.rs b/crates/skate-game/src/animation_pose.rs index c1462740..36ffaa38 100644 --- a/crates/skate-game/src/animation_pose.rs +++ b/crates/skate-game/src/animation_pose.rs @@ -17,6 +17,7 @@ mod authored_clips; pub(crate) struct PoseEvaluator { pub frames: AnimationFrames, authored: authored_clips::Replacements, + mod_clips: std::sync::RwLock>, } 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, String> { + let mod_clips = self.mod_clips.read().map_err(|_| "Animation override lock poisoned")?; let mut stack: Vec> = 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)?; diff --git a/crates/skate-game/src/animation_pose/authored_clips.rs b/crates/skate-game/src/animation_pose/authored_clips.rs index 7601a2d7..c3e950bc 100644 --- a/crates/skate-game/src/animation_pose/authored_clips.rs +++ b/crates/skate-game/src/animation_pose/authored_clips.rs @@ -4,7 +4,7 @@ use bevy::math::{Mat4, Quat, Vec3}; use serde::Deserialize; use std::collections::BTreeMap; #[derive(Default)] -pub(super) struct Replacements(BTreeMap); +pub(super) struct Replacements(pub(super) BTreeMap); #[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 { + pub(super) fn parse(text: &str, frames: &AnimationFrames) -> Result { 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()); diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index e75ef255..ccd12db7 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -106,6 +106,6 @@ pub(crate) fn build( verification::VerificationPlugin, crate::performance::PerformancePlugin, )); - app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); + app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin, crate::modding::ModdingPlugin)); app } diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 14f0089b..9b82a897 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -209,7 +209,7 @@ fn setup( BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..11 { + for i in 0..12 { panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); @@ -251,6 +251,7 @@ pub(crate) fn interact( mut config: ResMut, mut transition: ResMut, mut customiser: ResMut, + mut mods: ResMut, nav: Res, mut physics: ResMut, keys: Res>, @@ -264,17 +265,17 @@ pub(crate) fn interact( time.pause(); return; } - if customiser.open { return; } + if customiser.open || mods.open { return; } if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 { menu.open = !menu.open; } let mut action = None; if menu.open { if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { - menu.selected = (menu.selected + 10) % 11; + menu.selected = (menu.selected + 11) % 12; } if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { - menu.selected = (menu.selected + 1) % 11; + menu.selected = (menu.selected + 1) % 12; } if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { action = Some((menu.selected, -1)); @@ -329,6 +330,7 @@ pub(crate) fn interact( exit.write(AppExit::Success); } 10 => customiser.begin(), + 11 => mods.begin(), _ => {} } if row < 5 { @@ -404,13 +406,14 @@ fn labels( transition: Res, time: Res>, customiser: Res, + mods: Res, window: Single<&Window, With>, mut root: Single<&mut Node, With>, mut labels: Query<(&MenuLabel, &mut Text), Without>, mut status: Single<&mut Text, With>, mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>, ) { - root.display = if menu.open && !customiser.open { + root.display = if menu.open && !customiser.open && !mods.open { Display::Flex } else { Display::None @@ -449,7 +452,8 @@ fn labels( 7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() }, 8 => "Resume".into(), 9 => "Quit game".into(), - _ => "Character customiser".into(), + 10 => "Character customiser".into(), + _ => "Mods".into(), }; } ***status = if transition.busy() { diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 50ce415b..a5f84423 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -18,6 +18,7 @@ mod skater_animation; mod verification; mod performance; mod graphics_menu; +mod modding; mod customiser; mod customiser_parts; mod customiser_material; diff --git a/crates/skate-game/src/modding/menu.rs b/crates/skate-game/src/modding/menu.rs new file mode 100644 index 00000000..5aaa01e0 --- /dev/null +++ b/crates/skate-game/src/modding/menu.rs @@ -0,0 +1,339 @@ +use super::Mods; +use bevy::prelude::*; +#[derive(Resource, Default)] +pub(crate) struct ModMenu { + pub open: bool, + just_opened: bool, + id: Option, + selected: usize, + editing: Option<(String, String)>, + status: String, +} +impl ModMenu { + 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 Detail; +#[derive(Clone)] +enum Action { + Select(String), + Enable(String), + Reload(String), + Reset(String), + Setting(String, String), + Scan, + 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![ + ( + format!( + "{} — {}", + if p.enabled { "Disable" } else { "Enable" }, + if p.running() { + "Running" + } else if p.error.is_some() { + "Error" + } else { + "Disabled" + } + ), + 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)| { + ( + format!( + "{} [{}]", + p.manifest.name, + if p.running() { + "Running" + } else if p.error.is_some() { + "Error" + } else { + "Disabled" + } + ), + Action::Select(id.clone()), + ) + }) + .collect(); + 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)),..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)));}); } + 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, + mut mods: ResMut, + keys: Res>, + nav: Res, + buttons: Query<(&Interaction, &Row), Changed>, + mut typing: MessageReader, +) { + 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::Scan => { + mods.manager.scan(true); + Ok(()) + } + Action::Enable(id) => { + let enabled = !mods.manager.packages[&id].enabled; + 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, + mods: Res, + mut root: Single<&mut Node, With>, + mut labels: Query<(&Label, &mut Text)>, + mut buttons: Query<(&Row, &mut Node, &mut BackgroundColor), Without>, + mut detail: Single<&mut Text, (With, Without