diff --git a/crates/skate-dynamics/src/lib.rs b/crates/skate-dynamics/src/lib.rs index a33c10aa..28f3358b 100644 --- a/crates/skate-dynamics/src/lib.rs +++ b/crates/skate-dynamics/src/lib.rs @@ -6,6 +6,7 @@ use std::collections::{BTreeMap, HashMap}; pub use rapier3d; pub mod solid; +pub mod visual_contact; pub mod model; pub mod definition_codec; pub use model::ModelColliderOptions; @@ -710,6 +711,27 @@ impl DynamicsWorld { }) } + /// Geometric overlap with externally solved shapes, including sensors. + /// This query never creates a second simulation body or applies impulses. + pub fn overlaps_shapes(&self, id: u64, shapes: &[(SharedShape, Pose)]) -> bool { + let Some(body) = self.ids.get(&id).and_then(|h| self.bodies.get(*h)) else { return false; }; + body.colliders().iter().filter_map(|h| self.colliders.get(*h)).any(|c| { + let collider_pose = c.position_wrt_parent().map_or(*c.position(), |local| *body.position() * *local); + let bounds=c.shape().compute_aabb(&collider_pose).loosened(0.025); + c.is_enabled() && shapes.iter().any(|(shape, pose)| { + use rapier3d::parry::bounding_volume::BoundingVolume; + if !bounds.intersects(&shape.compute_aabb(pose)) {return false;} + if c.is_sensor() { + rapier3d::parry::query::intersection_test(&collider_pose, c.shape(), pose, &**shape).unwrap_or(false) + } else { + // Native BoardStep uses a 2.5cm contact prediction margin. + rapier3d::parry::query::contact(&collider_pose, c.shape(), pose, &**shape, 0.025) + .ok().flatten().is_some() + } + }) + }) + } + pub fn read(&self, id: u64) -> Option { let handle = *self.ids.get(&id)?; let body = self.bodies.get(handle)?; @@ -1802,6 +1824,20 @@ fn inertia_half_from_shape(shape: &Shape) -> [f32; 3] { mod tests { use super::*; + #[test] + fn external_shapes_detect_sensor_intersection_and_solid_contact_margin() { + let mut world=DynamicsWorld::default(); + let sensor=world.spawn(BodyDesc {sensor:true, ..box_desc(BodyType::Static,[0.,40.,0.],[1.,0.5,1.])}).unwrap(); + let solid=world.spawn(box_desc(BodyType::Static,[0.,40.,0.],[1.,0.5,1.])).unwrap(); + let shape=SharedShape::ball(0.1); + let near=[(shape.clone(),Pose::from_translation(Vector::new(0.,40.61,0.)))]; + assert!(!world.overlaps_shapes(sensor,&near));assert!(world.overlaps_shapes(solid,&near)); + let inside=[(shape,Pose::from_translation(Vector::new(0.,40.55,0.)))]; + assert!(world.overlaps_shapes(sensor,&inside));assert!(!world.overlaps_shapes(sensor,&[])); + assert!(world.set_pose(sensor,[10.,40.,0.],[0.,0.,0.,1.])); + assert!(!world.overlaps_shapes(sensor,&inside)); + } + fn box_desc(body_type: BodyType, position: [f32; 3], half: [f32; 3]) -> BodyDesc { BodyDesc { shape: Shape::Box { diff --git a/crates/skate-dynamics/src/visual_contact.rs b/crates/skate-dynamics/src/visual_contact.rs new file mode 100644 index 00000000..aaacd92e --- /dev/null +++ b/crates/skate-dynamics/src/visual_contact.rs @@ -0,0 +1,333 @@ +//! Presentation-only contact correction for buffered remote skaters. This never +//! applies impulses or changes an owned body. It prevents a current-frame car +//! from being drawn through a player whose authoritative pose is still in flight. +use crate::{SolidBody, SolidCollider}; +use rapier3d::{ + parry::query::{self, ShapeCastOptions}, + prelude::*, +}; + +pub fn resolve( + parts: &[SolidCollider], + previous_parts: &[SolidCollider], + solids: &[SolidBody], + previous_solids: &[SolidBody], +) -> Vector { + const SKIN: f32 = 0.01; + let mut offset = Vector::ZERO; + if parts.is_empty() || solids.is_empty() { + return offset; + } + let previous_bounds: Vec<_> = previous_parts + .iter() + .map(|p| p.shape.compute_aabb(&p.pose)) + .collect(); + let current_bounds: Vec<_> = parts + .iter() + .map(|p| p.shape.compute_aabb(&p.pose)) + .collect(); + let mut actor_min = Vector::splat(f32::INFINITY); + let mut actor_max = Vector::splat(f32::NEG_INFINITY); + for b in previous_bounds.iter().chain(¤t_bounds) { + actor_min = actor_min.min(b.mins); + actor_max = actor_max.max(b.maxs); + } + let overlaps = |amin: Vector, amax: Vector, bmin: Vector, bmax: Vector| { + amin.x <= bmax.x + SKIN + && amax.x >= bmin.x - SKIN + && amin.y <= bmax.y + SKIN + && amax.y >= bmin.y - SKIN + && amin.z <= bmax.z + SKIN + && amax.z >= bmin.z - SKIN + }; + let current_colliders: Vec<_> = solids + .iter() + .flat_map(|b| &b.colliders) + .map(|c| (c, c.shape.compute_aabb(&c.pose))) + .collect(); + // Sweep translation between rendered frames, preserving which side of a + // fast car hit the skater even if the car crosses them in a single frame. + if parts.len() == previous_parts.len() { + for body in solids { + let Some(old_body) = previous_solids.iter().find(|old| old.id == body.id) else { + continue; + }; + if body.colliders.len() != old_body.colliders.len() { + continue; + } + // An explicit relocation must not sweep a car across the map. + if body + .pose + .translation + .distance_squared(old_body.pose.translation) + > 400. + { + continue; + } + for (collider, old) in body.colliders.iter().zip(&old_body.colliders) { + let car_motion = collider.pose.translation - old.pose.translation; + let bounds_b = old.shape.compute_aabb(&old.pose); + let end_b = collider.shape.compute_aabb(&collider.pose); + if !overlaps( + actor_min + offset.min(Vector::ZERO), + actor_max + offset.max(Vector::ZERO), + bounds_b.mins.min(end_b.mins), + bounds_b.maxs.max(end_b.maxs), + ) { + continue; + } + for ((part, before), bounds_a) in + parts.iter().zip(previous_parts).zip(&previous_bounds) + { + let motion = part.pose.translation + offset - before.pose.translation; + let relative = motion - car_motion; + if relative.length_squared() < 1e-10 { + continue; + } + + let ra = (bounds_a.maxs - bounds_a.mins).length() * 0.5; + let rb = (bounds_b.maxs - bounds_b.mins).length() * 0.5; + let ca = (bounds_a.maxs + bounds_a.mins) * 0.5; + let cb = (bounds_b.maxs + bounds_b.mins) * 0.5; + if ca.distance_squared(cb) > (ra + rb + relative.length() + SKIN).powi(2) { + continue; + } + let options = ShapeCastOptions { + max_time_of_impact: 1., + target_distance: SKIN, + stop_at_penetration: false, + compute_impact_geometry_on_penetration: true, + }; + let Ok(Some(hit)) = query::cast_shapes( + &before.pose, + motion, + &*part.shape, + &old.pose, + car_motion, + &*old.shape, + options, + ) else { + continue; + }; + let normal = old.pose.rotation * hit.normal2; + let inward = relative.dot(normal); + if normal.is_finite() && inward < -1e-6 { + offset += normal * (-inward * (1. - hit.time_of_impact.clamp(0., 1.))); + } + } + } + } + } + // Resolve existing overlap too (spawn, rotation, first visible frame). + // Use actual solid shapes, and translate the whole rendered rig together. + for _ in 0..8 { + let mut deepest: Option<(f32, Vector)> = None; + for part in parts { + let mut pose = part.pose; + pose.translation += offset; + let a = part.shape.compute_aabb(&pose); + for (collider, b) in ¤t_colliders { + if (a.mins.x > b.maxs.x + SKIN || a.maxs.x < b.mins.x - SKIN) + || (a.mins.y > b.maxs.y + SKIN || a.maxs.y < b.mins.y - SKIN) + || (a.mins.z > b.maxs.z + SKIN || a.maxs.z < b.mins.z - SKIN) + { + continue; + } + let Ok(Some(c)) = + query::contact(&pose, &*part.shape, &collider.pose, &*collider.shape, SKIN) + else { + continue; + }; + if c.dist < 0. + && c.dist.is_finite() + && c.normal2.is_finite() + && deepest.as_ref().is_none_or(|(d, _)| c.dist < *d) + { + deepest = Some((c.dist, c.normal2)); + } + } + } + let Some((distance, normal)) = deepest else { + break; + }; + offset += normal * (-distance + SKIN); + } + offset +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[ignore = "manual timing probe, no machine-dependent pass threshold"] + fn visual_contact_timing_probe() { + use std::{hint::black_box, time::Instant}; + let points: Vec<_> = [-0.25, 0.25] + .into_iter() + .flat_map(|x| { + [-0.3, 0.3] + .into_iter() + .flat_map(move |y| [-0.5, 0.5].into_iter().map(move |z| Vector::new(x, y, z))) + }) + .collect(); + let hull = SharedShape::convex_hull(&points).unwrap(); + let mut vehicle = car(0.); + vehicle.colliders = (0..32) + .map(|i| SolidCollider { + shape: hull.clone(), + pose: Pose::translation( + (i % 4) as f32 * 0.5 - 0.75, + (i / 16) as f32 * 0.6, + (i / 4 % 4) as f32 - 1.5, + ), + friction: 0., + }) + .collect(); + for (label, players, cars, x) in [ + ("one nearby player, one car", 1, 1, 1.1), + ("one player being hit, one car", 1, 1, 0.98), + ("nine distant players, one car", 9, 1, 30.), + ("nine nearby players, nine cars", 9, 9, 1.1), + ("nine players being hit, nine cars", 9, 9, 0.98), + ] { + let solids: Vec<_> = (0..cars) + .map(|i| { + let mut b = vehicle.clone(); + b.id = i + 1; + for c in &mut b.colliders { + c.pose.translation.z += i as f32 * 6.; + } + b + }) + .collect(); + let old: Vec<_> = solids + .iter() + .map(|b| { + let mut b = b.clone(); + b.pose.translation.x -= 0.25; + for c in &mut b.colliders { + c.pose.translation.x -= 0.25; + } + b + }) + .collect(); + let parts: Vec<_> = (0..33) + .map(|i| SolidCollider { + shape: SharedShape::capsule_y(0.08, 0.08), + pose: Pose::translation(x, (i % 11) as f32 * 0.15, (i / 11) as f32 * 0.2 - 0.2), + friction: 0., + }) + .collect(); + for _ in 0..10 { + black_box(resolve(&parts, &parts, &solids, &old)); + } + let start = Instant::now(); + for _ in 0..200 { + for _ in 0..players { + black_box(resolve(black_box(&parts), &parts, &solids, &old)); + } + } + println!( + "{label}: {:.3} ms/frame (synthetic 33-part players, 32 convex hulls/car)", + start.elapsed().as_secs_f64() * 1000. / 200. + ); + } + } + fn car(x: f32) -> SolidBody { + let mut world = crate::DynamicsWorld::default(); + world + .spawn(crate::BodyDesc { + shape: crate::Shape::Box { + half_extents: [1., 1., 2.], + }, + position: [x, 0., 0.], + ..Default::default() + }) + .unwrap(); + world.solid_bodies().remove(0) + } + fn player(x: f32) -> Vec { + vec![SolidCollider { + shape: SharedShape::ball(0.3), + pose: Pose::translation(x, 0., 0.), + friction: 0., + }] + } + #[test] + fn fast_car_crossing_buffered_player_keeps_player_on_impact_side() { + let before = car(-4.); + let after = car(4.); + let p = player(0.); + let offset = resolve(&p, &p, &[after], &[before]); + assert!(offset.x > 5.29 && offset.x < 5.32, "{offset:?}"); + } + #[test] + fn buffered_victim_stays_outside_car_until_authoritative_pose_catches_up() { + for fps in [30., 60., 120., 144., 400.] { + let mut previous_car = car(-5.); + let mut previous_player = player(0.); + for frame in 1..=(fps as usize / 3) { + let time = frame as f32 / fps; + let current_car = car(-5. + 100. * time); + // Victim reacts correctly, but observers receive/render it 150ms late. + let desired = (-5. + 100. * (time - 0.15) + 1.31).max(0.); + let mut displayed = player(desired); + let correction = resolve( + &displayed, + &previous_player, + std::slice::from_ref(¤t_car), + std::slice::from_ref(&previous_car), + ); + displayed[0].pose.translation += correction; + let contact = query::contact( + &displayed[0].pose, + &*displayed[0].shape, + ¤t_car.colliders[0].pose, + &*current_car.colliders[0].shape, + 0., + ) + .unwrap(); + assert!( + contact.is_none_or(|c| c.dist >= -0.001), + "fps={fps}, frame={frame}" + ); + assert!(displayed[0].pose.translation.x >= 0.); + previous_car = current_car; + previous_player = displayed; + } + // An authoritative pose beyond the car no longer needs any correction. + let free = player(previous_car.pose.translation.x + 5.); + let correction = resolve( + &free, + &previous_player, + std::slice::from_ref(&previous_car), + std::slice::from_ref(&previous_car), + ); + assert!(correction.length() < 0.001); + } + } + #[test] + fn overlap_is_removed_without_sweeping_history() { + let offset = resolve(&player(0.8), &[], &[car(0.)], &[]); + assert!(0.8 + offset.x > 1.3); + } + #[test] + fn moving_away_does_not_pull_player_back_to_car() { + let offset = resolve(&player(3.), &player(1.31), &[car(0.)], &[car(0.)]); + assert!(offset.length() < 1e-5); + } + #[test] + fn near_miss_and_car_teleport_do_not_push_player() { + let p = player(0.); + let mut a = car(-4.); + let mut b = car(4.); + for body in [&mut a, &mut b] { + body.pose.translation.y = 5.; + for c in &mut body.colliders { + c.pose.translation.y = 5.; + } + } + assert!(resolve(&p, &p, &[b], &[a]).length() < 1e-5); + assert!(resolve(&p, &p, &[car(30.)], &[car(-30.)]).length() < 1e-5); + } +} diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index ec695ef9..d3dac268 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -249,21 +249,6 @@ pub(crate) fn native_matrix(matrix: NativeMatrix) -> Mat4 { ) } -/// Read the final displayed hierarchy, including stance blends. -pub(crate) fn network_visual(world: &mut World) -> skate_net::packed::PoseState { - let root=world.query_filtered::<&Transform,With>().iter(world).next().copied().unwrap_or_default(); - let status=world.resource::(); - let mut locals=std::collections::BTreeMap::new(); - for b in &status.bindings {if let Some(t)=world.get::(b.entity) {locals.entry(b.bone).or_insert((b.parent_bone,t.to_matrix()));}} - fn global(i:usize,locals:&std::collections::BTreeMap,Mat4)>,depth:usize)->Mat4 { - if depth>128 {return Mat4::IDENTITY;} - let Some(&(parent,m))=locals.get(&i) else {return Mat4::IDENTITY;}; - parent.map_or(m,|p|global(p,locals,depth+1)*m) - } - let anchors=crate::physics::network::anchors(world.resource::()); - let basis=render_basis().inverse(); - skate_net::packed::PoseState{root:crate::physics::network::pose(root.to_matrix()),bones:anchors.into_iter().map(|i|skate_net::Bone{index:i as u16,pose:crate::physics::network::pose(if locals.contains_key(&i) {global(i,&locals,0)*basis} else {native_matrix(world.resource::().render_pose[i])})}).collect()} -} #[cfg(test)] mod online_swap_tests { use super::*; diff --git a/crates/skate-game/src/animation_pose.rs b/crates/skate-game/src/animation_pose.rs index c1462740..97f46564 100644 --- a/crates/skate-game/src/animation_pose.rs +++ b/crates/skate-game/src/animation_pose.rs @@ -20,6 +20,7 @@ pub(crate) struct PoseEvaluator { } impl PoseEvaluator { + #[cfg(test)] pub fn load(asset_root: &Path) -> Result { let mut evaluator = Self::from_banks(&AnimationBanks::load(asset_root)?)?; evaluator.load_authored_clips(asset_root)?; diff --git a/crates/skate-game/src/apt_scene.rs b/crates/skate-game/src/apt_scene.rs index 6c8e75a9..d3840ef9 100644 --- a/crates/skate-game/src/apt_scene.rs +++ b/crates/skate-game/src/apt_scene.rs @@ -23,7 +23,6 @@ pub struct Shape { pub type Shapes = BTreeMap>; pub struct Draw { pub texture: String, - pub size: [u32; 2], pub vertices: Vec, pub multiply: [f32; 4], pub add: [f32; 4], @@ -109,7 +108,6 @@ fn visit( { out.push(Draw { texture: shape.texture.rgba.clone(), - size: [shape.texture.width, shape.texture.height], vertices: shape .triangles .iter() @@ -194,7 +192,6 @@ fn visit( if !vertices.is_empty() { out.push(Draw { texture: font.texture.clone(), - size: font.size, vertices, multiply: std::array::from_fn(|i| { if shadow && i < 3 { diff --git a/crates/skate-game/src/apt_vm.rs b/crates/skate-game/src/apt_vm.rs index da12fdef..6e7c4c12 100644 --- a/crates/skate-game/src/apt_vm.rs +++ b/crates/skate-game/src/apt_vm.rs @@ -240,21 +240,6 @@ impl Vm { pub fn begin_update(&mut self) { self.remaining = 100_000; } - pub fn construct( - &mut self, - class: usize, - args: Vec, - host: &mut impl Host, - ) -> Result { - let id = self.object(ObjectKind::Plain); - if let Value::Object(proto) = self.get(class, "prototype") { - self.objects[id].prototype = Some(proto); - } - if let ObjectKind::Function(f) = self.objects[class].kind.clone() { - self.invoke(f, id, args, host)?; - } - Ok(id) - } pub fn run(&mut self, code: &[Instruction], host: &mut impl Host) -> Result { self.begin_update(); self.run_on(self.global, code, host) diff --git a/crates/skate-game/src/camera/world_query.rs b/crates/skate-game/src/camera/world_query.rs index 3243de44..cb07fbaa 100644 --- a/crates/skate-game/src/camera/world_query.rs +++ b/crates/skate-game/src/camera/world_query.rs @@ -28,6 +28,14 @@ pub(super) fn line(world: &BoardWorld, start: [f32; 4], end: [f32; 4], radius: f } } } + if let Some(external) = world.external_line(start, vector(end), radius) { + let hit = external.hit.geometry; + let candidate = hit.fraction.clamp(0.0, 1.0); + if candidate < fraction { + result = FatLineResult { position: lanes(hit.position), normal: lanes(hit.normal), + fraction: candidate, hit: 1, surface: external.hit.tag }; + } + } Ok(result) } pub(super) fn no_hit() -> FatLineResult { diff --git a/crates/skate-game/src/fps_overlay.rs b/crates/skate-game/src/fps_overlay.rs index 95eb0f4c..dcac9d28 100644 --- a/crates/skate-game/src/fps_overlay.rs +++ b/crates/skate-game/src/fps_overlay.rs @@ -57,6 +57,11 @@ fn update( sample.seconds += seconds; if sample.seconds >= 0.5 { let fps = f64::from(sample.frames) / sample.seconds; + // Opt-in measurement of the existing display counter: no extra render + // systems, GPU queries or schedule barriers in ordinary gameplay. + if std::env::var_os("SKATE_FPS_LOG").is_some() { + eprintln!("SKATE_FPS_SAMPLE frames={} seconds={:.9} fps={fps:.3}", sample.frames, sample.seconds); + } for mut text in &mut labels { text.0 = format!("FPS: {fps:.0}"); } diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index b0f6c5a5..f8c1fa9c 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -95,9 +95,7 @@ pub(crate) struct Menu { pending_travel: Option<(Option, [[f32; 4]; 4])>, } impl Menu { - pub(crate) fn ambient_brightness(&self, automatic: f32) -> f32 { - self.settings.ambient_level.map_or(automatic, |level| level as f32 * 10.) - } + #[cfg(test)] pub(crate) fn advance_day(&mut self, seconds: f32) -> f32 { if !self.open && self.settings.day_speed > 0 { self.settings.hour = (self.settings.hour + seconds * self.settings.day_speed as f32 / 3600.).rem_euclid(24.); @@ -134,7 +132,8 @@ impl Menu { 1 => vec![3,4,10], 2 => vec![5,7,10], 3 => vec![0,1,10], - _ => vec![2,6,13,14], + 4 => (20..27).chain([10]).collect(), + _ => vec![2,6,13,14,15], }}; } match self.section { @@ -278,7 +277,7 @@ fn setup( body.spawn((Text::new(""),MenuSubtitle,TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.65,0.72,0.72)))); body.spawn((Node {height:px(3),width:px(64),margin:UiRect::bottom(px(10)),..default()},BackgroundColor(Color::srgb(0.78,0.96,0.3)))); body.spawn((MenuScroll,ScrollPosition::default(),Node {flex_grow:1.,min_height:px(0),overflow:Overflow::scroll_y(),flex_direction:FlexDirection::Column,row_gap:px(8),..default()})).with_children(|list| { - for i in (0..15).chain(200..264).chain([50,51]).chain(1000..1000+maps.len()).chain(1_000_000..1_000_000+destinations.len()) { + for i in (0..10).chain(11..16).chain(20..27).chain([10]).chain(200..264).chain([50,51]).chain(1000..1000+maps.len()).chain(1_000_000..1_000_000+destinations.len()) { list.spawn((Button,MenuRow(i),Node {width:percent(100),min_height:px(56),flex_shrink:0.,padding:UiRect::axes(px(18),px(12)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(4)),..default()},BackgroundColor(Color::srgb(0.075,0.09,0.095)))) .with_child((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); } @@ -351,7 +350,7 @@ pub(crate) fn interact( } if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 || (menu.open && nav.pressed & 0x2000 != 0) { if menu.open && menu.multiplayer && (menu.browser || menu.network_page != 0) { - menu.browser = false; if menu.network_page==1 {menu.browser=true;menu.network_page=0;menu.selected=8;} else {menu.network_page = 0; menu.selected = 2;} + menu.browser = false; if menu.network_page==1 {menu.browser=true;menu.network_page=0;menu.selected=8;} else {let from_debug=menu.network_page==4;menu.network_page = 0; menu.selected = if from_debug {15} else {2};} } else if menu.open && menu.map_detail { menu.map_detail = false; menu.selected = 1000 + menu.selected_map; @@ -520,10 +519,11 @@ pub(crate) fn interact( exit.write(AppExit::Success); } 10 => { - if menu.network_page==1 {menu.browser=true;menu.network_page=0;menu.selected=8;} else {menu.network_page = 0; menu.selected = 2;} + if menu.network_page==1 {menu.browser=true;menu.network_page=0;menu.selected=8;} else {let from_debug=menu.network_page==4;menu.network_page = 0; menu.selected = if from_debug {15} else {2};} } 13 => { menu.network_page = 2; menu.selected = 7; } 14 => { menu.network_page = 3; menu.selected = 0; } + 15 => { menu.network_page = 4; menu.selected = 20; } _ => {} } } else { @@ -609,7 +609,6 @@ fn toggle_fullscreen( .resolution .set_physical_resolution(menu.settings.width, menu.settings.height); } - _ => {} } } fn apply( @@ -654,6 +653,7 @@ fn labels( mut labels: Query<(&MenuLabel, &mut Text), (Without, Without, Without)>, mut headings: Query<(&mut Text, Has), (Or<(With, With)>, Without)>, mut status: Single<&mut Text, With>, + debug: (Res, Res, Res), mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without>, ) { root.display = if menu.open && !travel.open && !customiser.open && !custom_models.open && !mods.open { @@ -676,11 +676,19 @@ fn labels( match (menu.network_page, title) { (1,true) => "JOIN A FRIEND", (1,false) => "Select the code field, type your friend's code, then choose Join session.", (2,true) => "PLAYER & SESSION", (2,false) => "Select your name to edit it. Leave your current session here.", + (4,true) => "MULTIPLAYER DEBUG", (4,false) => "Live network and mod diagnostics. Scroll or use Up/Down to inspect.", (3,true) => "LOCAL TESTING", (_,false) => "Advanced: host or join a local test session without Steam.", _ => "MULTIPLAYER", } } else if title { SECTIONS.get(menu.section).map_or_else(||menu.custom_sections.get(menu.section-SECTIONS.len()).map_or("",|(n,_)|n.as_str()),|s|s.0) } else { SECTIONS.get(menu.section).map_or("Choose an activity.",|s|s.1) }.into(); } + let mut debug_rows = Vec::new(); + if menu.multiplayer && menu.network_page == 4 { + debug_rows.extend(net.debug_sections()); + debug_rows.push(format!("CHARACTERS & COLLISIONS\n{} {}\nPlayer contacts: {} | Network active: {}", + debug.2.progress, debug.2.status, debug.1.network_contacts, debug.1.network_active)); + debug_rows.extend(debug.0.multiplayer_debug_sections()); + } let s = &menu.settings; let size = s.internal_size(window.physical_size()); for (label, mut text) in &mut labels { @@ -696,6 +704,8 @@ fn labels( } else if label.0 == 51 { "Skate from default spawn".into() } else if label.0 >= 1000 { menu.maps.get(label.0 - 1000).map(|entry| format!("{} / VIEW SPOTS", entry.label)).unwrap_or_default() + } else if menu.multiplayer && menu.network_page == 4 && (20..27).contains(&label.0) { + debug_rows.get(label.0 - 20).cloned().unwrap_or_default() } else if menu.daylight { match label.0 { 0 => { let minutes = (s.hour * 60.).floor() as u32 % 1440; format!("Time of day {:02}:{:02}", minutes / 60, minutes % 60) }, @@ -753,6 +763,7 @@ fn labels( 9 => "Quit game".into(), 13 => "Player & session".into(), 14 => "Advanced / local testing".into(), + 15 => "Debug".into(), _ => "< Multiplayer".into(), } } else { @@ -785,6 +796,8 @@ fn labels( } ***status = if transition.busy() { format!("{} {}\nGameplay is paused. Please wait.", ["|", "/", "-", "\\"][(time.elapsed_secs() * 4.) as usize % 4], transition.label()) + } else if menu.multiplayer && menu.network_page == 4 { + "Diagnostics stay in this menu; gameplay shows names and ping only.".into() } else if menu.browser { net.browser_status.clone() } else if menu.multiplayer { @@ -970,7 +983,7 @@ mod tests { let rows = menu.rows(); assert!(rows.contains(&menu.selected)); assert!(rows.windows(2).all(|pair| pair[0] < pair[1])); - assert!(rows.iter().all(|id| *id < 15 || *id >= 1000)); + assert!(rows.iter().all(|id| *id < 16 || *id >= 1000)); } menu.select_section(1); assert_eq!(menu.rows(),vec![3,8,10]); @@ -981,8 +994,8 @@ mod tests { assert_eq!(menu.rows(), (1000..1040).collect::>()); menu.maps[0].path = Some(PathBuf::from("University.skate")); menu.destinations = vec![ - crate::teleport_menu::Destination { id: "uni".into(), name: "Campus".into(), map: "University".into(), matrix: Some([[0.;4];4]), unavailable_reason: None }, - crate::teleport_menu::Destination { id: "dt".into(), name: "Downtown".into(), map: "DownTown".into(), matrix: Some([[0.;4];4]), unavailable_reason: None }, + crate::teleport_menu::Destination { id: "uni".into(), name: "Campus".into(), map: "University".into(), matrix: Some([[0.;4];4]) }, + crate::teleport_menu::Destination { id: "dt".into(), name: "Downtown".into(), map: "DownTown".into(), matrix: Some([[0.;4];4]) }, ]; menu.map_detail = true; assert_eq!(menu.rows(), vec![50,51,1_000_000]); @@ -993,7 +1006,9 @@ mod tests { assert!(menu.rows().is_empty()); menu.select_section(3); assert!(menu.multiplayer); - assert_eq!(menu.rows(), vec![2,6,13,14]); + assert_eq!(menu.rows(), vec![2,6,13,14,15]); + menu.network_page = 4; + assert_eq!(menu.rows(), (20..27).chain([10]).collect::>()); menu.network_page = 1; assert_eq!(menu.rows(), vec![3,4,10]); menu.network_page = 3; diff --git a/crates/skate-game/src/grind_world.rs b/crates/skate-game/src/grind_world.rs index ab4de041..3f67dbce 100644 --- a/crates/skate-game/src/grind_world.rs +++ b/crates/skate-game/src/grind_world.rs @@ -105,16 +105,7 @@ fn box_faces(out: &mut Vec<[Vector3; 4]>, min: Vector3, max: Vector3) { mod spline; mod provider; mod octree; -pub(crate) use spline::{primitives, PrimitiveMetadata}; -pub(crate) use provider::{SourceIdentity, StaticProvider}; - -/// Retain the archive's relocatable Pegasus representation, including all 120 -/// authored bytes per segment. Omitted runtime pointers are rebuilt as offsets. -pub(crate) fn native_blob( - map: Option<&skate_data::skate_map::SkateMap>, -) -> Result, String> { - spline::build(map) -} +pub(crate) use provider::StaticProvider; #[cfg(test)] mod tests; diff --git a/crates/skate-game/src/grind_world/provider.rs b/crates/skate-game/src/grind_world/provider.rs index 9f6901c0..5eafeff5 100644 --- a/crates/skate-game/src/grind_world/provider.rs +++ b/crates/skate-game/src/grind_world/provider.rs @@ -14,6 +14,7 @@ pub(crate) struct SourceIdentity { pub section_offset: u64, } struct Asset { + #[cfg(test)] source: SourceIdentity, bounds: Bounds, indices: Vec, @@ -22,11 +23,12 @@ struct Asset { pub(crate) struct StaticProvider { primitives: Vec, metadata: Vec, + #[cfg(test)] rail_guids: Vec<[u64; 2]>, assets: Vec, - /// Parallel to primitives; no endpoint-derived replacement boxes. - authored_bounds: Vec, + #[cfg(test)] source_for_primitive: Vec, + #[cfg(test)] source_rail_indices: Vec, } @@ -35,10 +37,10 @@ impl StaticProvider { /// Missing provenance is an explicit conversion prerequisite, not a ray miss. pub fn new(map: Option<&SkateMap>) -> Result { let Some(map) = map else { - return Ok(Self { primitives: vec![], metadata: vec![], rail_guids: vec![], assets: vec![], authored_bounds: vec![], source_for_primitive: vec![], source_rail_indices: vec![] }); + return Ok(Self { primitives: vec![], metadata: vec![], #[cfg(test)] rail_guids: vec![], assets: vec![], #[cfg(test)] source_for_primitive: vec![], #[cfg(test)] source_rail_indices: vec![] }); }; if map.rails.is_empty() { - return Ok(Self { primitives: vec![], metadata: vec![], rail_guids: vec![], assets: vec![], authored_bounds: vec![], source_for_primitive: vec![], source_rail_indices: vec![] }); + return Ok(Self { primitives: vec![], metadata: vec![], #[cfg(test)] rail_guids: vec![], assets: vec![], #[cfg(test)] source_for_primitive: vec![], #[cfg(test)] source_rail_indices: vec![] }); } if map.rails.iter().all(|rail| rail.native.is_none()) { return Self::authored(&map.rails); } let mut metadata = map.extensions.iter().filter(|e| e.tag == *b"WMET"); @@ -55,10 +57,12 @@ impl StaticProvider { let word = |at: usize| u32::from_be_bytes(bytes[at..at+4].try_into().unwrap()); let mut grouped: Vec<(SourceIdentity, Vec)> = Vec::new(); let mut source_index = HashMap::new(); - let mut authored_bounds = Vec::with_capacity(primitives.len()); let mut spatial_bounds = Vec::with_capacity(primitives.len()); + #[cfg(test)] let mut source_for_primitive = Vec::with_capacity(primitives.len()); + #[cfg(test)] let mut source_rail_indices = Vec::with_capacity(primitives.len()); + #[cfg(test)] let mut rail_guids = Vec::with_capacity(map.rails.len()); let mut ordinal = 0; for (rail, (record, package_rail)) in records.iter().zip(&map.rails).enumerate() { @@ -76,6 +80,7 @@ impl StaticProvider { let count = (last-first)/144+1; let spline_id = ((word(header) as u64)<<32)|word(header+4) as u64; let type_signature = ((word(header+8) as u64)<<32)|word(header+12) as u64; + #[cfg(test)] rail_guids.push([spline_id, type_signature]); if parse_id(record, "spline_id")? != spline_id || number(record, "segment_count")? != count as u64 || parse_id(record, "type_signature")? != type_signature @@ -93,27 +98,29 @@ impl StaticProvider { min: std::array::from_fn(|i| f32::from_bits(word(at+80+i*4))), max: std::array::from_fn(|i| f32::from_bits(word(at+96+i*4))), }; - authored_bounds.push(bounds); spatial_bounds.push(bounds.identity_transformed()); grouped[asset].1.push(ordinal); + #[cfg(test)] source_for_primitive.push(asset); + #[cfg(test)] source_rail_indices.push(source_rail); ordinal+=1; } } - let assets = grouped.into_iter().map(|(source, indices)| { + let assets = grouped.into_iter().map(|(_source, indices)| { let bounds = indices.iter().map(|&i| spatial_bounds[i]).reduce(Bounds::union) .ok_or("Empty stock grind source section")?.padded(); let tree = Octree::new(bounds, indices.iter().map(|&i| spatial_bounds[i]).collect())?; - Ok(Asset { source, bounds, indices, tree }) + Ok(Asset { #[cfg(test)] source: _source, bounds, indices, tree }) }).collect::>()?; - Ok(Self { primitives, metadata, rail_guids, assets, authored_bounds, source_for_primitive, source_rail_indices }) + Ok(Self { primitives, metadata, #[cfg(test)] rail_guids, assets, #[cfg(test)] source_for_primitive, #[cfg(test)] source_rail_indices }) } /// Explicit host-authored polylines have no retail source section identity. pub fn authored(rails: &[skate_data::skate_map::Rail]) -> Result { let bytes = spline::build_rails(rails)?; let (primitives, metadata) = spline::decoded_from_blob(&bytes)?; + #[cfg(test)] let rail_guids = (0..rails.len()).map(|i| { let at = 16 + i * 32; [u64::from_be_bytes(bytes[at..at+8].try_into().unwrap()), @@ -126,14 +133,15 @@ impl StaticProvider { let assets = if let Some(bounds) = authored_bounds.iter().copied().reduce(Bounds::union) { let bounds = bounds.padded(); vec![Asset { + #[cfg(test)] source: SourceIdentity { stream_file: String::new(), asset_id: "host-authored".into(), section_index: 0, section_offset: 0 }, bounds, indices: (0..primitives.len()).collect(), tree: Octree::new(bounds, authored_bounds.clone())?, }] } else { vec![] }; + #[cfg(test)] let source_rail_indices = primitives.iter().map(|p| p.owner - 1).collect(); - Ok(Self { source_for_primitive: vec![0; primitives.len()], primitives, metadata, - rail_guids, assets, authored_bounds, source_rail_indices }) + Ok(Self { #[cfg(test)] source_for_primitive: vec![0; primitives.len()], primitives, metadata, #[cfg(test)] rail_guids, assets, #[cfg(test)] source_rail_indices }) } pub fn primitives(&self) -> &[Primitive] { &self.primitives } @@ -143,23 +151,22 @@ impl StaticProvider { } /// Resolve contact's map-local header handle without confusing it with GUID. + #[cfg(test)] pub fn spline_guids(&self, owner: u64) -> Option<[u64; 2]> { let rail = usize::try_from(owner.checked_sub(1)?).ok()?; self.rail_guids.get(rail).copied() } + #[cfg(test)] pub fn source_rail_index(&self, primitive: usize) -> Option { self.source_rail_indices.get(primitive).copied() } + #[cfg(test)] pub fn source(&self, primitive: usize) -> Option<&SourceIdentity> { self.source_for_primitive.get(primitive).map(|&asset| &self.assets[asset].source) } - pub fn bounds(&self, primitive: usize) -> Option<([f32; 3], [f32; 3])> { - self.authored_bounds.get(primitive).map(|b| (b.min, b.max)) - } - /// S3 82C1EAD8 static pass only: query gate, ordered asset overlap and /// native per-asset octree traversal, at most forty original vector indices. /// Does not implement or pretend to query unavailable moving providers. diff --git a/crates/skate-game/src/grind_world/spline.rs b/crates/skate-game/src/grind_world/spline.rs index 4d9d0f94..bc3919b7 100644 --- a/crates/skate-game/src/grind_world/spline.rs +++ b/crates/skate-game/src/grind_world/spline.rs @@ -107,17 +107,8 @@ pub(super) fn build_rails(rails: &[Rail]) -> Result, String> { Ok(bytes) } -pub(crate) fn primitives(map: Option<&SkateMap>) -> Result,String> { - let bytes = build(map)?; - primitives_from_blob(&bytes) -} - // Only accepts a blob returned by build/build_rails; public package input is // validated there. Owner is a map-local header handle, not the repeating ID. -pub(super) fn primitives_from_blob(bytes: &[u8]) -> Result,String> { - decoded_from_blob(bytes).map(|(primitives, _)| primitives) -} - pub(super) fn decoded_from_blob(bytes: &[u8]) -> Result<(Vec, Vec),String> { let word = |at| u32::from_be_bytes(bytes[at..at+4].try_into().unwrap()); let mut result = Vec::new(); diff --git a/crates/skate-game/src/grind_world/tests.rs b/crates/skate-game/src/grind_world/tests.rs index ade4b6b6..6eed709a 100644 --- a/crates/skate-game/src/grind_world/tests.rs +++ b/crates/skate-game/src/grind_world/tests.rs @@ -17,7 +17,7 @@ fn native(id: u64, length: f32) -> Rail { #[test] fn no_map_keeps_current_flat_course_empty() { - assert!(primitives(None).unwrap().is_empty()); + assert!(StaticProvider::new(None).unwrap().primitives().is_empty()); let provider = StaticProvider::new(None).unwrap(); assert!(provider.query([-1.; 3], [1.; 3]).unwrap().is_empty()); } diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 2fa9ed0d..9fdad17a 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -63,12 +63,17 @@ pub(crate) fn publish_actions( menu: Option>, debug: Res, camera: Res, + mods: Option>, ) { - if !crate::graphics_menu::gameplay_active(menu) || debug.suppress_gameplay(&camera) { + let blocked = !crate::graphics_menu::gameplay_active(menu) || debug.suppress_gameplay(&camera); + if blocked { input.discard_gameplay(); } input.publish_actions(); let tick = input.tick_input(); + let mut values=*tick.actions().values(); + if !blocked { crate::modding::override_actions(mods.as_deref(), &mut values); } + let tick=TickInput::new(tick.tick(),skate_core::input::gameplay_map::GameplayActions::from_values(values),tick.controller_available()); published.0 = if debug.suppress_gameplay(&camera) { TickInput::new( tick.tick(), @@ -79,6 +84,3 @@ pub(crate) fn publish_actions( tick }; } - -#[cfg(test)] -pub(crate) mod manual_replay; diff --git a/crates/skate-game/src/input/controllers.rs b/crates/skate-game/src/input/controllers.rs index 1985db5d..fff887a6 100644 --- a/crates/skate-game/src/input/controllers.rs +++ b/crates/skate-game/src/input/controllers.rs @@ -76,6 +76,7 @@ impl ControllerInput { self.pads = std::array::from_fn(|_| Pad::new()); self.mapped_actions = [[0.0; 18]; DEVICE_SLOTS]; } + #[cfg(test)] pub(crate) fn player_actions(&self) -> GameplayActions { let device = self.status.iter().position(|status| *status == ControllerStatus::Ready).unwrap_or(0); GameplayActions::from_pad(&self.pads[device]) diff --git a/crates/skate-game/src/input/manual_replay.rs b/crates/skate-game/src/input/manual_replay.rs deleted file mode 100644 index 01701625..00000000 --- a/crates/skate-game/src/input/manual_replay.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Opt-in verification device packets, never a gameplay state/force override. -//! The same packets run in production-frame tests and the normal rendered game. -use skate_core::input::xbox::XboxState; - -pub(crate) const END_TICK: u64 = 330; - -pub(crate) fn sample(tick: u64, sign: i16) -> XboxState { - XboxState { - buttons: if (12..40).contains(&tick) { 0x1000 } else { 0 }, - triggers: [0; 2], - left: [0; 2], - right: if (45..195).contains(&tick) { - [0, sign * 22000] - } else { - [0; 2] - }, - } -} diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 6e4aad0f..0ba7ac8d 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -214,12 +214,6 @@ impl PreparedScene { Box::new(std::mem::replace(&mut self.params, StagedAssets::new(world))), ], }); - world.insert_resource(crate::map_transition::MapRenderBudget { - draws: self.stats.draws, - triangles: self.stats.triangles, - leaves: self.stats.leaves, - slabs: self.stats.slabs, - }); } } diff --git a/crates/skate-game/src/map_transition.rs b/crates/skate-game/src/map_transition.rs index 00356dda..091bbc1d 100644 --- a/crates/skate-game/src/map_transition.rs +++ b/crates/skate-game/src/map_transition.rs @@ -10,22 +10,8 @@ use crate::{config::Config, map_library::Entry, map_render::PreparedScene, #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct MapTransitionSet; #[derive(Message)] -pub(crate) struct WorldChanged { pub generation: u64 } +pub(crate) struct WorldChanged; -/// What the prepared scene actually cost, recorded when geometry is published. -/// -/// This used to gate MSAA and occlusion culling against a batch count, because -/// draw count varied with material count and could reach thousands. Draw count -/// is now a property of the spatial partition we choose, so there is nothing -/// left to adapt to: the render options are fixed policy (RFC 1 D7) and this is -/// a plain report for the perf harness and the debug overlay. -#[derive(Resource, Default, Clone, Copy, Debug)] -pub(crate) struct MapRenderBudget { - pub draws: usize, - pub triangles: usize, - pub leaves: usize, - pub slabs: usize, -} #[cfg(test)] #[path = "tests/map_transition.rs"] @@ -97,7 +83,6 @@ impl Plugin for MapTransitionPlugin { let current = CurrentMap::from_package(config.map_path.clone(), config.map.as_ref()); app.insert_resource(current) .init_resource::() - .init_resource::() .add_message::() .add_systems(PreUpdate, poll.in_set(MapTransitionSet).after(crate::graphics_menu::MenuInput) .before(crate::input::poll_controllers)) @@ -248,7 +233,7 @@ fn commit(world: &mut World, mut prepared: PreparedWorld) -> String { prepared.metadata.generation, prepared.metadata.name, prepared.metadata.spawn, prepared.metadata.heading, std::process::id()); if let Some(mut messages) = world.get_resource_mut::>() { - messages.write(WorldChanged { generation: prepared.metadata.generation }); + messages.write(WorldChanged); } world.insert_resource(prepared.metadata); eprintln!("MAP_PUBLISH_TIMING cpu_ms={}", publication_started.elapsed().as_millis()); diff --git a/crates/skate-game/src/modding/bridge.rs b/crates/skate-game/src/modding/bridge.rs index 75235d9e..b795272c 100644 --- a/crates/skate-game/src/modding/bridge.rs +++ b/crates/skate-game/src/modding/bridge.rs @@ -124,3 +124,20 @@ pub(crate) fn dynamics_to_board( } pub(crate) fn sync_network(world: &mut World) { super::replication::sync(world); } + +/// Enabled native board/skater volumes, without a duplicate Rapier actor. +pub(super) fn player_shapes(world: &World) -> Vec<(skate_dynamics::rapier3d::prelude::SharedShape, skate_dynamics::rapier3d::prelude::Pose)> { + let physics = world.resource::(); + let skater = world.resource::(); + let mut volumes = crate::physics::colliders::world_volumes(&physics.board, &physics.settings); + volumes.retain(|v| skater.board_possession_live.volume_enabled(v.body)); + if let Ok(skeleton) = crate::physics::skeleton_colliders::volumes_with_parts(&skater.skeleton, &super::player_physics::collision_parts(skater)) { + volumes.extend(skeleton); + } + volumes.into_iter().filter_map(|v| crate::physics::solid_contacts::shape(v.primitive)).collect() +} + +/// Current solid geometry for remote-player presentation clearance only. +pub(crate) fn visual_solids(mods: &Mods) -> Vec { + mods.world.solid_bodies() +} diff --git a/crates/skate-game/src/modding/camera_stream.rs b/crates/skate-game/src/modding/camera_stream.rs new file mode 100644 index 00000000..73f2c308 --- /dev/null +++ b/crates/skate-game/src/modding/camera_stream.rs @@ -0,0 +1,49 @@ +//! Authenticated peer camera transforms for the generic camera.watch API. +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +pub(super) const KEY: &str = "s2:camera"; +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct View { p: [f32; 3], q: [f32; 4], pub fov: f32 } +impl View { + fn valid(&self) -> bool { + self.p.iter().all(|x| x.is_finite() && x.abs() <= 100_000.) + && self.q.iter().all(|x| x.is_finite()) + && (0.9..1.1).contains(&self.q.iter().map(|x| x*x).sum::()) + && self.fov.is_finite() && (0.1..3.1).contains(&self.fov) + } + pub fn transform(&self) -> Transform { + Transform::from_translation(Vec3::from_array(self.p)).with_rotation(Quat::from_array(self.q).normalize()) + } +} +pub(super) fn capture(world: &mut World) -> Option> { + let mut cameras = world.query_filtered::<(&Transform, &Projection), With>(); + let (t, p) = cameras.iter(world).next()?; + let Projection::Perspective(p) = p else { return None; }; + let view = View { p: t.translation.to_array(), q: t.rotation.to_array(), fov: p.fov }; + view.valid().then(|| serde_json::to_vec(&view).ok()).flatten() +} +pub(super) fn ingest(views: &mut BTreeMap, records: &[(u64,String,u32,Vec)], players: &[u64]) { + views.clear(); + for (peer,key,_,bytes) in records { + if key != KEY || !players.contains(peer) {continue;} + if let Ok(view) = serde_json::from_slice::(bytes) { + if view.valid() {views.insert(*peer,view);} + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn camera_packets_require_connected_peer_and_valid_pose() { + let mut views = BTreeMap::new(); + let view = View {p:[1.,2.,3.],q:[0.,0.,0.,1.],fov:1.}; + let record = (7,KEY.into(),1,serde_json::to_vec(&view).unwrap()); + ingest(&mut views,&[record.clone()],&[]); assert!(views.is_empty()); + ingest(&mut views,&[record],&[7]); assert_eq!(views[&7].transform().translation,Vec3::new(1.,2.,3.)); + let invalid=View {q:[0.;4],..view}; + ingest(&mut views,&[(7,KEY.into(),2,serde_json::to_vec(&invalid).unwrap())],&[7]); assert!(views.is_empty()); + } +} diff --git a/crates/skate-game/src/modding/canvas.rs b/crates/skate-game/src/modding/canvas.rs index dbbf1ceb..649121ea 100644 --- a/crates/skate-game/src/modding/canvas.rs +++ b/crates/skate-game/src/modding/canvas.rs @@ -43,13 +43,13 @@ pub(super) fn set(world: &mut World, canvases: &mut BTreeMap<(String,String),Can }); let color=Color::srgba(item.color[0],item.color[1],item.color[2],item.color[3]); match item.kind { - CanvasKind::Rect => { world.entity_mut(e).insert(BackgroundColor(color)); } + CanvasKind::Rect => { if world.get::(e).is_none_or(|c|c.0!=color){world.entity_mut(e).insert(BackgroundColor(color));} } CanvasKind::Text => { // Reuse entities; replace only changed text, avoiding needless reshaping. if world.get::(e).is_none_or(|t| t.0!=item.text) { world.entity_mut(e).insert(Text::new(item.text.clone())); } - world.entity_mut(e).insert(TextColor(color)); + if world.get::(e).is_none_or(|c|c.0!=color){world.entity_mut(e).insert(TextColor(color));} } } } @@ -78,14 +78,15 @@ pub(super) fn present(world:&mut World, canvases:&BTreeMap<(String,String),Canva CanvasAnchor::BottomLeft => {node.left=px(o.offset[0]*scale);node.bottom=px(o.offset[1]*scale);} CanvasAnchor::BottomRight => {node.right=px(o.offset[0]*scale);node.bottom=px(o.offset[1]*scale);} } - if world.get::(c.root).is_some(){world.entity_mut(c.root).insert(node);} + if world.get::(c.root).is_some_and(|old|*old!=node){world.entity_mut(c.root).insert(node);} for (index,item) in o.items.iter().enumerate() { let Some((e,_))=c.nodes.get(&item.key) else {continue}; if world.get::(*e).is_none(){continue;} - world.entity_mut(*e).insert(ZIndex(index as i32)); - world.entity_mut(*e).insert(Node{position_type:PositionType::Absolute, + if world.get::(*e).is_none_or(|z|z.0!=index as i32){world.entity_mut(*e).insert(ZIndex(index as i32));} + let node=Node{position_type:PositionType::Absolute, left:px(item.position[0]*scale),top:px(item.position[1]*scale), - width:px(item.size[0]*scale),height:px(item.size[1]*scale),..default()}); + width:px(item.size[0]*scale),height:px(item.size[1]*scale),..default()}; + if world.get::(*e).is_some_and(|old|*old!=node){world.entity_mut(*e).insert(node);} if item.kind==CanvasKind::Text && world.get::(*e) .is_none_or(|font| (font.font_size-item.font_size*scale).abs()>0.01) { world.entity_mut(*e).insert(TextFont{font_size:item.font_size*scale,..default()}); @@ -93,3 +94,36 @@ pub(super) fn present(world:&mut World, canvases:&BTreeMap<(String,String),Canva } } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unchanged_canvas_does_not_dirty_layout_or_text() { + let mut world = World::new(); + let mut canvases = BTreeMap::new(); + let mut options = CanvasOptions::default(); + options.items.push(skate_mods::presentation::CanvasItem { + key: "status".into(), text: "No broken bones".into(), ..default() + }); + set(&mut world, &mut canvases, "test", "hud".into(), options.clone()).unwrap(); + present(&mut world, &canvases, false); + let canvas = &canvases[&("test".into(), "hud".into())]; + let root = canvas.root; + let text = canvas.nodes["status"].0; + world.clear_trackers(); + set(&mut world, &mut canvases, "test", "hud".into(), options.clone()).unwrap(); + present(&mut world, &canvases, false); + assert!(!world.entity(root).get_ref::().unwrap().is_changed()); + assert!(!world.entity(text).get_ref::().unwrap().is_changed()); + assert!(!world.entity(text).get_ref::().unwrap().is_changed()); + assert!(!world.entity(text).get_ref::().unwrap().is_changed()); + assert!(!world.entity(text).get_ref::().unwrap().is_changed()); + assert!(!world.entity(text).get_ref::().unwrap().is_changed()); + options.items[0].text = "Arm broken".into(); + set(&mut world, &mut canvases, "test", "hud".into(), options).unwrap(); + assert!(world.entity(text).get_ref::().unwrap().is_changed()); + present(&mut world, &canvases, true); + assert_eq!(world.get::(root).unwrap().display, Display::None); + } +} diff --git a/crates/skate-game/src/modding/engine_access.rs b/crates/skate-game/src/modding/engine_access.rs new file mode 100644 index 00000000..8ae915c3 --- /dev/null +++ b/crates/skate-game/src/modding/engine_access.rs @@ -0,0 +1,104 @@ +//! Read-only structured access to native systems; catalogs are requested on demand. +use crate::{ + graph_runtime::{LoadedGraph, StockGraphs}, + physics::SkaterRuntime, +}; +use bevy::prelude::*; +use serde_json::{Value, json}; +fn graph_state(c: &skate_core::graph::controller::Controller, g: Option<&LoadedGraph>) -> Value { + json!({"current":c.frame.current,"previous":c.frame.last,"dt":c.frame.dt, + "name":c.frame.current.and_then(|id|g.and_then(|g|g.binding.states.get(id)).map(|s|&s.name)), + "state_times":c.frame.state_times,"active_behaviors":c.active.iter().map(|b|b.behavior).collect::>()}) +} +pub(super) fn snapshot(world: &World) -> Value { + let s = world.resource::(); + let graphs = world.get_resource::(); + json!({"graphs":{"tick":s.animation.ticks,"action":graph_state(&s.animation.action_controller,graphs.map(|g|&g.action)), + "motion":graph_state(&s.animation.motion_controller,graphs.map(|g|&g.motion))}, + "animation":{"tick":s.animation.ticks,"pose_generation":s.pose_generation,"bone_count":s.animation.pose.len()}, + "systems":["player","rig","bodies","input","graphs","animation","scoring","world","camera","network","commands"]}) +} +fn graph(g: &LoadedGraph) -> Value { + json!({"behaviors":g.runtime.program.behaviors.iter().enumerate().map(|(id,b)|json!({"id":id,"enabled":b.enabled,"operation_id":g.runtime.operations.behaviors.get(id)})).collect::>(),"behavior_operation_ids":g.runtime.operations.behaviors,"states":g.binding.states.iter().enumerate().map(|(id,s)|json!({"id":id,"name":s.name,"parent":s.parent,"children":s.children,"behaviors":s.behaviors,"transitions":s.transitions,"authored_enabled":s.enabled!=0,"enabled":g.runtime.program.topology.states.get(id).map(|s|s.enabled)})).collect::>(), + "transitions":g.binding.transitions.iter().enumerate().map(|(id,t)|json!({"id":id,"owner":t.owner,"target":t.target,"priority":t.priority,"authored_enabled":t.enabled!=0,"enabled":g.runtime.program.topology.transitions.get(id).map(|t|t.enabled),"hooks":t.hooks})).collect::>(), + "operations":g.binding.operations.iter().enumerate().map(|(id,o)|json!({"id":id,"name":o.name,"kind":format!("{:?}",o.kind),"enabled":o.enabled!=0})).collect::>()}) +} +pub(super) fn inspect(world: &World, system: &str) -> Value { + match system { + "graphs" => world.get_resource::().map_or( + Value::Null, + |g| json!({"action":graph(&g.action),"motion":graph(&g.motion)}), + ), + "scoring" => world.resource::().scoring.mod_catalog(), + _ => Value::Null, + } +} + +fn flag<'a>( + g: &'a mut StockGraphs, + graph: &str, + target: &str, + index: usize, +) -> Option<&'a mut bool> { + let p = &mut if graph == "action" { + &mut g.action + } else { + &mut g.motion + } + .runtime + .program; + match target { + "state" => p.topology.states.get_mut(index).map(|s| &mut s.enabled), + "transition" => p + .topology + .transitions + .get_mut(index) + .map(|t| &mut t.enabled), + "behavior" => p.behaviors.get_mut(index).map(|b| &mut b.enabled), + _ => None, + } +} +pub(super) fn gate( + world: &mut World, + mods: &mut super::Mods, + owner: &str, + graph: String, + target: String, + index: usize, + enabled: Option, +) -> Result<(), String> { + let key = (graph, target, index); + if mods.graph_gates.get(&key).is_some_and(|(o, _)| o != owner) { + return Err("graph gate is owned by another mod".into()); + } + let mut graphs = world + .get_resource_mut::() + .ok_or("graphs unavailable")?; + let f = flag(&mut graphs, &key.0, &key.1, key.2).ok_or("unknown graph element")?; + if let Some(enabled) = enabled { + mods.graph_gates + .entry(key) + .or_insert((owner.to_owned(), *f)); + *f = enabled; + } else if let Some((_, original)) = mods.graph_gates.remove(&key) { + *f = original; + } + Ok(()) +} +pub(super) fn restore_gates(world: &mut World, mods: &mut super::Mods, owner: Option<&str>) { + let keys: Vec<_> = mods + .graph_gates + .iter() + .filter(|(_, (o, _))| owner.is_none_or(|owner| o == owner)) + .map(|(k, _)| k.clone()) + .collect(); + for key in keys { + if let Some((_, original)) = mods.graph_gates.remove(&key) { + if let Some(mut graphs) = world.get_resource_mut::() { + if let Some(f) = flag(&mut graphs, &key.0, &key.1, key.2) { + *f = original; + } + } + } + } +} diff --git a/crates/skate-game/src/modding/glb.rs b/crates/skate-game/src/modding/glb.rs index af43a3de..e5b98e81 100644 --- a/crates/skate-game/src/modding/glb.rs +++ b/crates/skate-game/src/modding/glb.rs @@ -1,28 +1,6 @@ //! Sync package-GLB helpers for named nodes/meshes → convex hull points. use std::path::Path; -#[derive(Debug, Clone, serde::Serialize)] -pub struct ObjectLists { - pub nodes: Vec, - pub meshes: Vec, -} - -pub fn list_objects(path: &Path) -> Result { - let (doc, _) = open(path)?; - let mut nodes: Vec<_> = doc - .nodes() - .filter_map(|n| n.name().map(str::to_owned)) - .collect(); - let mut meshes: Vec<_> = doc - .meshes() - .filter_map(|m| m.name().map(str::to_owned)) - .collect(); - nodes.sort(); - nodes.dedup(); - meshes.sort(); - meshes.dedup(); - Ok(ObjectLists { nodes, meshes }) -} /// Collect unique vertex positions for a named node (with descendants) or mesh, in that /// node's local space (identity if mesh-only). Suitable for Rapier convex hulls. @@ -30,26 +8,13 @@ pub fn convex_points(path: &Path, object: &str) -> Result, String> skate_mods::convex_points_file(path, object) } -fn open(path: &Path) -> Result<(gltf::Document, Vec), String> { - if !path.is_file() { - return Err(format!("GLB not found: {}", path.display())); - } - let meta = std::fs::metadata(path).map_err(|e| e.to_string())?; - if meta.len() > 64 * 1024 * 1024 { - return Err("GLB exceeds 64 MiB".into()); - } - let (doc, buffers, _) = - gltf::import(path).map_err(|e| format!("GLB load {}: {e}", path.display()))?; - Ok((doc, buffers)) -} - #[cfg(test)] mod tests { use super::*; #[test] fn missing_file_errors() { - assert!(list_objects(Path::new("nope.glb")).is_err()); + assert!(convex_points(Path::new("nope.glb"), "missing").is_err()); } #[test] diff --git a/crates/skate-game/src/modding/graphics.rs b/crates/skate-game/src/modding/graphics.rs index c4cbd104..f5926c37 100644 --- a/crates/skate-game/src/modding/graphics.rs +++ b/crates/skate-game/src/modding/graphics.rs @@ -88,7 +88,7 @@ pub(super) fn spawn( (world.spawn((Mesh3d(mesh.clone()),MeshMaterial3d(material.clone()),t,visibility)).id(),Some(mesh.id()),Some(material.id())) }; mods.graphics_serial=mods.graphics_serial.wrapping_add(1); - world.entity_mut(entity).insert((crate::retail_character::ModGraphicsLit,super::ModGraphicsOpacity(definition.opacity))); + world.entity_mut(entity).insert((crate::retail_character::ModGraphicsLit,bevy::camera::visibility::RenderLayers::from_layers(&[0,28]))); mods.graphics.insert(slot,Owned { entity,mesh,material,body:definition.body.clone(),definition,transform:state,visible, serial:serial.unwrap_or(mods.graphics_serial),nodes:BTreeMap::new(),bindings:BTreeMap::new(), @@ -112,13 +112,16 @@ pub(super) fn reset_node(world:&mut World,mods:&mut Mods,owner:&str,key:&str,nod } } -fn bind(world:&World,owned:&mut Owned) { +fn bind(world:&mut World,owned:&mut Owned) { if owned.ready { return; } if owned.definition.path.is_empty() { owned.ready=true; return; } let Some(instance)=world.get::(owned.entity) else { return }; let spawner=world.resource::(); if !spawner.instance_is_ready(**instance) { return; } - for entity in spawner.iter_instance_entities(**instance) { + let entities:Vec<_>=spawner.iter_instance_entities(**instance).collect(); + let mut opacity_materials=BTreeMap::new(); + for entity in entities { + prepare_mesh(world,entity,owned.definition.opacity,&mut opacity_materials); let (Some(name),Some(t))=(world.get::(entity),world.get::(entity)) else { continue }; let name=name.as_str().to_owned(); if owned.bindings.insert(name.clone(),Binding { entity,authored:*t }).is_some() { @@ -128,6 +131,25 @@ fn bind(world:&World,owned:&mut Owned) { owned.ready=true; } +fn prepare_mesh(world:&mut World,entity:Entity,opacity:f32,cache:&mut BTreeMap,Handle>) { + let Some(handle)=world.get::>(entity).map(|m|m.0.clone()) else {return}; + // The baked world's dynamic-shadow map includes layer 28. Keep authored + // materials (and their alpha-aware shadow shaders) for every GLB primitive. + let layers=world.get::(entity).cloned().unwrap_or_default().with(28); + world.entity_mut(entity).insert(layers); + if opacity>=0.999 {return;} + let replacement=if let Some(existing)=cache.get(&handle.id()) {existing.clone()} else { + let mut assets=world.resource_mut::>(); + let Some(mut material)=assets.get(&handle).cloned() else {return}; + material.base_color.set_alpha(material.base_color.alpha()*opacity); + material.alpha_mode=AlphaMode::Blend; + let replacement=assets.add(material); + cache.insert(handle.id(),replacement.clone()); + replacement + }; + world.entity_mut(entity).insert(MeshMaterial3d(replacement)); +} + pub(super) fn sync(world:&mut World,mods:&mut Mods) { for ((owner,key),owned) in &mut mods.graphics { let mut t=transform(&owned.transform); @@ -146,8 +168,8 @@ pub(super) fn sync(world:&mut World,mods:&mut Mods) { visible=false; } } - if let Some(mut current)=world.get_mut::(owned.entity) { *current=t; } - if let Some(mut current)=world.get_mut::(owned.entity) { *current=if visible { Visibility::Visible } else { Visibility::Hidden }; } + if let Some(mut current)=world.get_mut::(owned.entity) { if *current!=t {*current=t;} } + if let Some(mut current)=world.get_mut::(owned.entity) { let next=if visible { Visibility::Visible } else { Visibility::Hidden }; if *current!=next {*current=next;} } bind(world,owned); for (name,node) in &owned.nodes { if owned.ambiguous.contains(name) || !owned.bindings.contains_key(name) { @@ -159,7 +181,8 @@ pub(super) fn sync(world:&mut World,mods:&mut Mods) { let binding=owned.bindings[name]; let age=if owner.starts_with('@') { node.received.elapsed().as_secs_f32() } else { 0. }; if let Some(mut current)=world.get_mut::(binding.entity) { - *current=node_transform(binding.authored,&node.state,age); + let next=node_transform(binding.authored,&node.state,age); + if *current!=next {*current=next;} } } } @@ -200,6 +223,35 @@ pub(crate) fn debug(mods:Res,mut gizmos:Gizmos) { #[cfg(test)] mod tests { use super::*; + #[test] + fn authored_materials_cast_shadows_and_opacity_is_instance_local() { + use bevy::camera::visibility::RenderLayers; + let mut world=World::new(); + world.init_resource::>(); + let source=world.resource_mut::>().add(StandardMaterial { + metallic:0.8,perceptual_roughness:0.23,double_sided:true,cull_mode:None, + base_color:Color::srgba(0.2,0.3,0.4,0.6),alpha_mode:AlphaMode::Mask(0.3),..default() + }); + let original=world.spawn(MeshMaterial3d(source.clone())).id(); + let faded=world.spawn(MeshMaterial3d(source.clone())).id(); + let mut cache=BTreeMap::new(); + prepare_mesh(&mut world,original,1.,&mut cache); + prepare_mesh(&mut world,faded,0.5,&mut cache); + assert_eq!(world.get::>(original).unwrap().0,source); + for entity in [original,faded] { + assert!(world.get::(entity).unwrap().intersects(&RenderLayers::layer(28))); + } + let assets=world.resource::>(); + assert_eq!(assets.get(&source).unwrap().base_color.alpha(),0.6); + assert_eq!(assets.get(&source).unwrap().alpha_mode,AlphaMode::Mask(0.3)); + let material=assets.get(&world.get::>(faded).unwrap().0).unwrap(); + assert_eq!(material.base_color.alpha(),0.3); + assert_eq!(material.metallic,0.8); + assert_eq!(material.perceptual_roughness,0.23); + assert!(material.double_sided); + assert!(material.cull_mode.is_none()); + } + #[test] fn bind_delta_does_not_accumulate_or_discard_authored_scale() { let authored=Transform::from_xyz(2.,3.,4.).with_scale(Vec3::splat(2.)); let mut state=NodeState::default(); state.transform.position=[0.,0.2,0.]; diff --git a/crates/skate-game/src/modding/menu.rs b/crates/skate-game/src/modding/menu.rs index 39fc3744..32e419b4 100644 --- a/crates/skate-game/src/modding/menu.rs +++ b/crates/skate-game/src/modding/menu.rs @@ -16,10 +16,6 @@ impl ModMenu { pub(crate) fn open_registered(&mut self, owner:String, key:String) { self.begin(); self.id=Some(owner); self.custom=Some(key); self.return_to_pause=true; } - pub fn configure(&mut self, id: String) { - self.begin(); - self.id = Some(id); - } pub fn begin(&mut self) { self.open = true; self.return_to_pause = false; diff --git a/crates/skate-game/src/modding/mod.rs b/crates/skate-game/src/modding/mod.rs index d00c28b8..c483ba00 100644 --- a/crates/skate-game/src/modding/mod.rs +++ b/crates/skate-game/src/modding/mod.rs @@ -10,6 +10,10 @@ pub(crate) mod attachment; mod glb; mod menu; mod observation; +mod engine_access; +mod participation; +mod camera_stream; +pub(crate) use participation::{player_suspended, peer_suspended}; mod session; mod volumes; mod capture; @@ -34,6 +38,7 @@ use std::collections::{BTreeMap, BTreeSet}; #[derive(Resource)] pub(crate) struct Mods { pub manager: Manager, + native_snapshot: Option<([u64; 4], skate_mods::SnapshotFields)>, world: DynamicsWorld, bodies: BTreeMap<(String, String), u64>, joints: BTreeMap<(String, String), u64>, @@ -44,6 +49,9 @@ pub(crate) struct Mods { detach_error: Option, detach_pending: Option<(Transform,std::time::Instant)>, camera: CameraOverride, + suspended_by: BTreeSet, + hidden_players: BTreeMap, + remote_cameras: BTreeMap, generation: u64, graphics_serial: u64, debug_owners: BTreeSet, @@ -62,8 +70,12 @@ pub(crate) struct Mods { /// Maps (peer, wire_key) → (mod_id, key) for empty-value cleanup. net_remote_wire: BTreeMap<(u64, String), (String, String)>, net_status: String, + multiplayer_debug: BTreeMap<(String, String), String>, /// Contacts from the last DynamicsWorld::step (before drain). last_contacts: Vec, + command_results: BTreeMap<(String,String), Value>, + graph_gates: BTreeMap<(String,String,usize),(String,bool)>, + input_overrides: BTreeMap, session: session::Runtime, skater_remote: BTreeMap, pending_remote_teleport: Option, @@ -83,8 +95,6 @@ struct NetWire { #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct ModCameraSet; -#[derive(Component)] -pub(crate) struct ModGraphicsOpacity(pub f32); type GraphicsOwned = graphics::Owned; @@ -148,6 +158,7 @@ impl Plugin for ModdingPlugin { }); app.insert_resource(Mods { manager: Manager::new(root, settings), + native_snapshot: None, world: DynamicsWorld::default(), bodies: BTreeMap::new(), joints: BTreeMap::new(), @@ -170,12 +181,18 @@ impl Plugin for ModdingPlugin { net_remote: BTreeMap::new(), net_remote_wire: BTreeMap::new(), net_status: String::new(), + multiplayer_debug: BTreeMap::new(), last_contacts: Vec::new(), + graph_gates: BTreeMap::new(), + command_results: BTreeMap::new(), input_overrides: BTreeMap::new(), session: session::Runtime::default(), skater_remote: BTreeMap::new(), pending_remote_teleport: None, volumes: BTreeMap::new(), custom_menus: BTreeMap::new(), + suspended_by: BTreeSet::new(), + hidden_players: BTreeMap::new(), + remote_cameras: BTreeMap::new(), }) .init_resource::(); menu::install(app); @@ -205,7 +222,8 @@ impl Plugin for ModdingPlugin { ( update.after(crate::app::FrameSet::Animation), bridge::sync_network.after(crate::multiplayer::send_pose).after(update), - sync_net.after(bridge::sync_network), + sync_net.after(bridge::sync_network).after(ModCameraSet), + participation::present.after(update).after(crate::app::FrameSet::Animation), graphics::debug.after(bridge::sync_network).after(update), present_camera .in_set(ModCameraSet) @@ -258,7 +276,7 @@ impl Mods { } } -fn body_snapshot_for(mods: &Mods, owner: &str) -> serde_json::Value { +fn body_snapshot_for(mods: &Mods, owner: &str, player_shapes: &[(skate_dynamics::rapier3d::prelude::SharedShape, skate_dynamics::rapier3d::prelude::Pose)]) -> serde_json::Value { let mut bodies = serde_json::Map::new(); let mut reverse = BTreeMap::::new(); for ((o, key), id) in &mods.bodies { @@ -270,6 +288,7 @@ fn body_snapshot_for(mods: &Mods, owner: &str) -> serde_json::Value { bodies.insert( key.clone(), json!({ + "player_overlapping": mods.world.overlaps_shapes(*id, player_shapes), "position": snap.position, "rotation": snap.rotation, "linvel": snap.linvel, @@ -320,7 +339,7 @@ fn body_snapshot_for(mods: &Mods, owner: &str) -> serde_json::Value { fn network_snapshot(world: &World, mods: &Mods) -> Value { let net = world.get_resource::(); let (active, id, host) = net.map_or((false, 0, true), |n| n.mod_identity()); - let host_id = net.map_or(id, |n| n.host_peer()); + let host_id = net.map_or(id, |n| n.host_actor()); let mut players: Vec = net .map(|n| n.player_ids().into_iter().map(|p| p.to_string()).collect()) .unwrap_or_default(); @@ -406,8 +425,8 @@ fn maintenance(world: &mut World) { mods.manager.commands.clear(); if mods.runtime_busy() { let camera = camera_position(world); - let snap = snapshot_ro(world, &mods, camera); - mods.manager.snapshot = snap; + let snap = snapshot_ro(world, &mut mods, camera); + mods.manager.snapshot = std::sync::Arc::new(snap); let map = mods.manager.snapshot["map"].clone(); mods.manager .dispatch("on_event", json!({"name":"world_changed","map":map})); @@ -419,9 +438,8 @@ fn maintenance(world: &mut World) { if !mods.runtime_busy() { return; } - let camera = camera_position(world); - let snap = snapshot_ro(world, &mods, camera); - mods.manager.snapshot = snap; + // The last update already published observations. Maintenance only scans + // packages and applies pending lifecycle/menu commands. apply(world, &mut mods); }); } @@ -434,7 +452,25 @@ fn camera_position(world: &mut World) -> Option<[f32; 3]> { .map(|t| t.translation.to_array()) } -fn snapshot_ro(world: &World, mods: &Mods, camera: Option<[f32; 3]>) -> serde_json::Value { +fn snapshot_ro(world: &World, mods: &mut Mods, camera: Option<[f32; 3]>) -> serde_json::Value { + let _span = bevy::log::tracing::info_span!("mods.snapshot").entered(); + // The native state changes at simulation ticks. Render callbacks can reuse + // its serialization until either native resource changes, while inputs, + // command receipts, camera and network observations remain frame-current. + let key = [ + world.get_resource_ref::().unwrap().last_changed().get() as u64, + world.get_resource_ref::().unwrap().last_changed().get() as u64, + mods.bodies.len() as u64, + mods.generation, + ]; + if mods.native_snapshot.as_ref().is_none_or(|(previous, _)| *previous != key) { + let fields = BTreeMap::from([ + ("player_physics".into(), std::sync::Arc::new(player_physics::snapshot(world, mods))), + ("engine".into(), std::sync::Arc::new(engine_access::snapshot(world))), + ]); + mods.native_snapshot = Some((key, std::sync::Arc::new(fields))); + } + mods.manager.snapshot_fields = mods.native_snapshot.as_ref().unwrap().1.clone(); let map = world.resource::(); let physics = world.resource::(); let keys: BTreeMap<_, _> = world @@ -485,7 +521,9 @@ fn snapshot_ro(world: &World, mods: &Mods, camera: Option<[f32; 3]>) -> serde_js json!({ "player": player, "skaters": skaters, - "player_physics": player_physics::snapshot(world), + "command_results": mods.command_results.iter().fold(serde_json::Map::::new(), |mut out,((owner,key),value)| { + out.entry(owner.clone()).or_insert_with(||json!({}))[key]=value.clone();out + }), "volumes": mods.manager.packages.keys().map(|owner| { (owner.clone(), volumes::snapshot(mods, owner, &local_id, &observation::local(world), &mods.skater_remote)) }).collect::>(), @@ -512,6 +550,7 @@ fn snapshot_ro(world: &World, mods: &Mods, camera: Option<[f32; 3]>) -> serde_js } fn fixed(world: &mut World) { + let _span = bevy::log::tracing::info_span!("mods.fixed").entered(); if world.resource::().active { return; } @@ -522,7 +561,7 @@ fn fixed(world: &mut World) { if mods.runtime_busy() || mods.manager.packages.values().any(|package| package.running()) { - mods.manager.snapshot = snapshot_ro(world, &mods, camera); + mods.manager.snapshot = std::sync::Arc::new(snapshot_ro(world, &mut mods, camera)); } if let Err(e) = ensure_ground(world, &mut mods) { warn!("dynamics ground: {e}"); @@ -534,11 +573,12 @@ fn fixed(world: &mut World) { .filter(|(_, p)| p.running()) .map(|(id, _)| id.clone()) .collect(); + let player_shapes = if mods.suspended_by.is_empty() && mods.attach.is_none() { + bridge::player_shapes(world) + } else { Vec::new() }; for id in ids { - let physics = body_snapshot_for(&mods, &id); - if let Some(obj) = mods.manager.snapshot.as_object_mut() { - obj.insert("physics".into(), physics); - } + let _span = bevy::log::tracing::info_span!("mods.fixed_callback", mod_id = %id).entered(); + let physics = body_snapshot_for(&mods, &id, &player_shapes); // Sync dynamics queries for this mod while on_fixed_update runs. struct Host<'a> { world: &'a mut DynamicsWorld, @@ -633,7 +673,7 @@ fn fixed(world: &mut World) { owner: &id, }; with_host(&mut host, || { - manager.call(&id, "on_fixed_update", json!({"dt": dt})); + manager.call_with_physics(&id, "on_fixed_update", json!({"dt": dt}), physics); }); } // New command frame: drop last tick's user forces so they cannot stack in Rapier. @@ -648,6 +688,7 @@ fn fixed(world: &mut World) { bridge::push_skater_into_rapier(&mut mods, physics, skater); } if !mods.bodies.is_empty() || mods.ground_ready || !mods.skater_proxies.is_empty() { + let _span = bevy::log::tracing::info_span!("mods.dynamics_step").entered(); mods.world.step(dt as f32); mods.last_contacts = mods.world.drain_contacts(); } @@ -659,21 +700,22 @@ fn fixed(world: &mut World) { } fn update(world: &mut World) { + let _span = bevy::log::tracing::info_span!("mods.update").entered(); world.resource_scope(|world, mut mods: Mut| { if !mods.runtime_busy() { return; } let camera = camera_position(world); - let snap = snapshot_ro(world, &mods, camera); + let snap = snapshot_ro(world, &mut mods, camera); let paused = snap["paused"].as_bool().unwrap_or(true) || snap["replay"].as_bool().unwrap_or(false); let dt = world.resource::>().delta_secs_f64().min(0.25); - mods.manager.snapshot = snap; + mods.manager.snapshot = std::sync::Arc::new(snap); // UI state must refresh during pause (including network role changes). // This callback does not advance Lua simulation time or timers. - mods.manager.dispatch("on_ui_update", json!({"dt": dt, "paused": paused})); + dispatch_profiled(&mut mods.manager, "on_ui_update", json!({"dt": dt, "paused": paused})); if !paused { - mods.manager.dispatch("on_update", json!({"dt": dt})); + dispatch_profiled(&mut mods.manager, "on_update", json!({"dt": dt})); } apply(world, &mut mods); sync_graphics(world, &mut mods); @@ -700,6 +742,7 @@ fn clear_runtime(world: &mut World, mods: &mut Mods) { world.despawn(e); } } + mods.multiplayer_debug.clear(); mods.bodies.clear(); mods.joints.clear(); mods.skater_proxies.clear(); @@ -710,9 +753,13 @@ fn clear_runtime(world: &mut World, mods: &mut Mods) { mods.net_remote_wire.clear(); mods.net_status.clear(); mods.last_contacts.clear(); + engine_access::restore_gates(world,mods,None); + mods.command_results.clear();mods.input_overrides.clear(); mods.debug_owners.clear(); player_physics::clear(world,None); mods.custom_menus.clear(); + participation::clear(world, mods); + mods.remote_cameras.clear(); mods.session.reset(); mods.skater_remote.clear(); mods.pending_remote_teleport = None; @@ -739,8 +786,13 @@ fn retire_graphics(world: &mut World, mods: &mut Mods, key: &(String, String)) { } fn apply(world: &mut World, mods: &mut Mods) { + let _span = bevy::log::tracing::info_span!("mods.apply").entered(); let retired = std::mem::take(&mut mods.manager.retired); for id in &retired { + mods.suspended_by.remove(id); + engine_access::restore_gates(world,mods,Some(id)); + mods.command_results.retain(|(owner,_),_|owner!=id); + mods.input_overrides.retain(|_,(owner,_)|owner!=id); audio::stop_owner(world, id, true); graphics_dynamic::clear_owner(world, id); volumes::clear_owner(world, mods, id); @@ -761,6 +813,7 @@ fn apply(world: &mut World, mods: &mut Mods) { for key in gkeys { retire_graphics(world, mods, &key); } + mods.multiplayer_debug.retain(|(owner, _), _| owner != id); let okeys: Vec<_> = mods .overlays .keys() @@ -809,7 +862,9 @@ fn apply(world: &mut World, mods: &mut Mods) { } if let Err(e) = ensure_ground(world, mods) { warn!("Lua mod {id}: {e}"); + engine_access::restore_gates(world,mods,Some(&id)); mods.manager.fail(&id, e); + mods.input_overrides.retain(|_,(owner,_)|owner!=&id); audio::stop_owner(world, &id, true); graphics_dynamic::clear_owner(world, &id); volumes::clear_owner(world, mods, &id); @@ -832,7 +887,9 @@ fn apply(world: &mut World, mods: &mut Mods) { })(); if let Err(e) = result { warn!("Lua mod {id}: {e}"); + engine_access::restore_gates(world,mods,Some(&id)); mods.manager.fail(&id, e); + mods.input_overrides.retain(|_,(owner,_)|owner!=&id); audio::stop_owner(world, &id, true); graphics_dynamic::clear_owner(world, &id); volumes::clear_owner(world, mods, &id); @@ -844,7 +901,8 @@ fn apply(world: &mut World, mods: &mut Mods) { let mut row = 0; for entity in mods.overlays.values() { if let Some(mut node) = world.get_mut::(*entity) { - node.top = px(16. + row as f32 * 28.); + let top=px(16. + row as f32 * 28.); + if node.top!=top {node.top=top;} row += 1; } } @@ -857,6 +915,28 @@ fn apply_one( command: Command, ) -> Result<(), String> { match command { + Command::RigPart {index,options} => player_physics::set_part(world,id,index,options)?, + Command::GraphGate {graph,target,index,enabled} => engine_access::gate(world,mods,id,graph,target,index,enabled)?, + Command::EngineInspect {..} => return Err("use commands.request for engine inspection results".into()), + Command::Request {key,command,token} => { + let slot=(id.to_owned(),key); + if !mods.command_results.contains_key(&slot) && mods.command_results.keys().filter(|(o,_)|o==id).count()>=64 { + return Err("64 command result slots per mod maximum".into()); + } + let (result,value)=if let Command::EngineInspect {system}=*command { + (Ok(()),engine_access::inspect(world,&system)) + } else {(apply_one(world,mods,id,*command),Value::Null)}; + let tick=world.resource::().ticks; + mods.command_results.insert(slot,json!({"token":token,"ok":result.is_ok(),"error":result.err(),"value":value,"tick":tick})); + } + Command::InputOverride {action,value} => { + if mods.input_overrides.get(&action).is_some_and(|(o,_)|o!=id) { return Err("input action owned by another mod".into()); } + if let Some(value)=value {mods.input_overrides.insert(action,(id.to_owned(),value));} else {mods.input_overrides.remove(&action);} + } + Command::NativeImpulse {body,impulse,point,angular} => { + if mods.attach.is_some() || !mods.suspended_by.is_empty() {return Err("native player is attached or suspended".into());} + player_physics::impulse(world,&body,impulse,point,angular)?; + } Command::UiCanvas { key, options } => canvas::set(world, &mut mods.canvases, id, key, options)?, Command::UiRemove { key } => { let slot = (id.to_owned(), key); @@ -891,6 +971,17 @@ fn apply_one( } Command::GraphicsLight { key, options } => graphics_dynamic::light(world, mods, id, key, options)?, Command::Log { text } => info!("Lua [{id}]: {text}"), + Command::MultiplayerDebug { key, text } => { + let slot = (id.to_owned(), key); + if text.is_empty() { mods.multiplayer_debug.remove(&slot); } + else { + if !mods.multiplayer_debug.contains_key(&slot) + && mods.multiplayer_debug.keys().filter(|(owner,_)| owner == id).count() >= 8 { + return Err("8 multiplayer debug entries per mod maximum".into()); + } + mods.multiplayer_debug.insert(slot, text); + } + } Command::Overlay { key, text } => { let k = (id.to_owned(), key); // Empty overlay text removes its row rather than leaving a blank slot. @@ -900,7 +991,7 @@ fn apply_one( } if let Some(entity) = mods.overlays.get(&k) { if let Some(mut t) = world.get_mut::(*entity) { - **t = text; + if t.0!=text {**t = text;} return Ok(()); } } @@ -926,6 +1017,7 @@ fn apply_one( mods.overlays.insert(k, entity); } Command::PhysicsSpawn { key, body } => { + mods.native_snapshot = None; let k = (id.to_owned(), key); if let Some(old) = mods.bodies.remove(&k) { mods.world.remove(old); @@ -938,6 +1030,7 @@ fn apply_one( mods.bodies.insert(k, body_id); } Command::PhysicsRemove { key } => { + mods.native_snapshot = None; if mods.attach.as_ref().is_some_and(|a|a.owner==id && a.body==key) { detach_player(world,mods,true); } let bound:Vec<_>=mods.graphics.iter().filter(|((o,_),g)|o==id && g.body.as_deref()==Some(key.as_str())).map(|(k,_)|k.clone()).collect(); for slot in bound { retire_graphics(world,mods,&slot); } @@ -1136,6 +1229,10 @@ fn apply_one( look_at.map(Vec3::from_array), )); } + Command::PlayerSuspend { suspended } => { + if suspended { mods.suspended_by.insert(id.to_owned()); } + else { mods.suspended_by.remove(id); } + } Command::CameraWatch { peer } => { let empty = peer.as_deref().is_none_or(|p| p.is_empty()); if empty { @@ -1270,7 +1367,8 @@ fn sync_net(world: &mut World) { mods.net_states .retain(|(owner, _), _| packages.contains_key(owner)); mods.session.connect(world.get_resource::().and_then(|n| n.session_identity())); - let obs_bytes = observation::encode_local(world); + let obs_bytes = observation::encode_local(world, player_suspended(&mods)); + let view_bytes = camera_stream::capture(world); let Some(mut net) = world.get_resource_mut::() else { mods.net_published.clear(); @@ -1278,6 +1376,7 @@ fn sync_net(world: &mut World) { mods.net_remote_wire.clear(); mods.net_status.clear(); mods.skater_remote.clear(); + mods.remote_cameras.clear(); return; }; if !net.active() { @@ -1286,11 +1385,12 @@ fn sync_net(world: &mut World) { mods.net_remote_wire.clear(); mods.net_status.clear(); mods.skater_remote.clear(); + mods.remote_cameras.clear(); return; } let (_, local, is_host) = net.mod_identity(); - let host_peer = net.host_peer().to_string(); + let host_peer = net.host_actor().to_string(); let local_id = local.to_string(); let mut published = BTreeSet::new(); let mut failures = 0u32; @@ -1323,6 +1423,9 @@ fn sync_net(world: &mut World) { failures += 1; } } + if let Some(bytes) = view_bytes { + if net.publish_application(camera_stream::KEY, bytes) { published.insert(camera_stream::KEY.into()); } + } for (key, bytes) in session::outgoing(&mods, &local_id, is_host, &host_peer) { if net.publish_application(&key, bytes) { published.insert(key); @@ -1337,8 +1440,9 @@ fn sync_net(world: &mut World) { let records = net.application_records(); let players = net.player_ids(); - let host = net.host_peer(); + let host = net.host_actor(); drop(net); + camera_stream::ingest(&mut mods.remote_cameras, &records, &players); session::ingest(&mut mods, &records, local, host, &players); session::apply_pending_remote(world, &mut mods); @@ -1463,6 +1567,13 @@ fn present_camera(world: &mut World) { .map(|n| n.mod_identity().1) .unwrap_or(0); if peer != local_id { + if let Some(view) = mods.remote_cameras.get(&peer) { + if let Some(mut t) = world.get_mut::(camera) { *t = view.transform(); } + if let Some(mut p) = world.get_mut::(camera) { + if let Projection::Perspective(p) = &mut *p { p.fov = view.fov; } + } + return; + } if let Some((position, rotation)) = watch_pose(world, peer, local_id) { if let Some(mut t) = world.get_mut::(camera) { *t = spectator_view(position, rotation); @@ -1553,3 +1664,34 @@ fn watch_pose(world: &mut World, peer: u64, local_id: u64) -> Option<(Vec3, Quat .get_resource::() .and_then(|net| net.actor_pose(peer)) } + +impl Mods { + pub(crate) fn multiplayer_debug_sections(&self) -> [String; 3] { + let sync = format!("MOD REPLICATION\n{}\nLocal network keys: {} | Remote keys: {}\nPublished dynamics: {} | Local bodies: {} | Local scenes: {}", + if self.net_status.is_empty() { "No active mod replication" } else { &self.net_status }, + self.net_states.len(), self.net_remote.len(), self.dyn_published.len(), self.bodies.len(), self.graphics.len()); + let packages = self.manager.packages.values().map(|p| format!("{} — {}{}", + p.manifest.name, if p.running() { "running" } else if p.enabled { "enabled / stopped" } else { "disabled" }, + p.error.as_ref().map(|e| format!("\n{e}")).unwrap_or_default())).collect::>().join("\n"); + let reports = self.multiplayer_debug.iter().map(|((owner,key),text)| { + let name = self.manager.packages.get(owner).map_or(owner.as_str(), |p| p.manifest.name.as_str()); + format!("{name} / {key}\n{text}") + }).collect::>().join("\n\n"); + [sync, format!("MOD PACKAGES\n{}", if packages.is_empty() { "No packages installed" } else { &packages }), + format!("MOD DIAGNOSTICS\n{}", if reports.is_empty() { "No mod multiplayer diagnostics reported" } else { &reports })] + } +} + +pub(crate) fn override_actions(mods: Option<&Mods>, values: &mut [f32;18]) { + if let Some(mods)=mods { for (&id,(_,value)) in &mods.input_overrides {values[id-64]=*value;} } +} + +/// Retain dispatch ordering while attributing each Lua callback in CPU captures. +fn dispatch_profiled(manager: &mut skate_mods::Manager, callback: &str, payload: Value) { + let ids: Vec<_> = manager.packages.iter().filter(|(_, p)| p.running()) + .map(|(id, _)| id.clone()).collect(); + for id in ids { + let _span = bevy::log::tracing::info_span!("mods.callback", mod_id = %id, callback).entered(); + manager.call(&id, callback, payload.clone()); + } +} diff --git a/crates/skate-game/src/modding/observation.rs b/crates/skate-game/src/modding/observation.rs index 93546381..743ac1e0 100644 --- a/crates/skate-game/src/modding/observation.rs +++ b/crates/skate-game/src/modding/observation.rs @@ -17,6 +17,8 @@ const MAX_GRIND: usize = 16; #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct WireObs { + #[serde(default)] + pub suspended: bool, #[serde(default)] pub p: [f32; 3], #[serde(default)] @@ -50,6 +52,14 @@ pub(super) struct WireObs { #[serde(default)] pub landed_name: String, #[serde(default)] + pub lb: String, + #[serde(default)] + pub sd: i32, + #[serde(default)] + pub lc: bool, + #[serde(default)] + pub lk: bool, + #[serde(default)] pub bail_seq: u32, #[serde(default)] pub nt: bool, @@ -98,6 +108,7 @@ impl WireObs { && self.ls.is_finite() && self.mu.is_finite() && self.lt.is_finite() + && self.lb.len() <= 128 && self.sd.unsigned_abs() <= 100_000 && self.landed_name.len() <= MAX_TRICK && self.tr.len() <= MAX_TRICK && self.md.len() <= MAX_MODE @@ -110,8 +121,9 @@ impl WireObs { } } -pub(super) fn encode_local(world: &World) -> Option> { - let obs = local(world); +pub(super) fn encode_local(world: &World, suspended: bool) -> Option> { + let mut obs = local(world); + obs.suspended = suspended; let mut bytes = serde_json::to_vec(&obs).ok()?; if bytes.len() <= skate_net::lobby::MAX_APP_VALUE { return Some(bytes); @@ -173,6 +185,7 @@ pub(super) fn local(world: &World) -> WireObs { let mode = mode_name(cat, p.filtered_state_0, !grind.is_empty(), bail).to_owned(); let trick = crate::scoring_hud::display_trick(world, s.scoring.trick_name()); WireObs { + suspended: false, p: [root[3][0], root[3][1], root[3][2]], v: vel, av, @@ -188,6 +201,8 @@ pub(super) fn local(world: &World) -> WireObs { tr: bounded_label(&trick, MAX_TRICK), ts: s.scoring.trick_seq(), landed: s.scoring.landing_seq, + lb: bounded_label(&s.scoring.landed_base, 128), sd: s.scoring.landed_spin_degrees, + lc: s.scoring.landed_clean, lk: s.scoring.landed_sketchy, landed_name: bounded_label(&crate::scoring_hud::display_trick(world, &s.scoring.landed_trick), MAX_TRICK), bail_seq: s.scoring.bail_seq, nt: s.scoring.new_trick, @@ -241,6 +256,7 @@ fn lua_fields(obs: &WireObs) -> Map { let insert = |out: &mut Map, key: &str, value: Value| { out.insert(key.to_owned(), value); }; + insert(&mut out, "suspended", json!(obs.suspended)); insert(&mut out, "position", json!(obs.p)); insert(&mut out, "velocity", json!(obs.v)); insert(&mut out, "angvel", json!(obs.av)); @@ -259,6 +275,10 @@ fn lua_fields(obs: &WireObs) -> Map { insert(&mut out, "trick_seq", json!(obs.ts)); insert(&mut out, "landing_seq", json!(obs.landed)); insert(&mut out, "landed_trick", json!(obs.landed_name)); + insert(&mut out, "landed_trick_base", json!(obs.lb)); + insert(&mut out, "landed_spin_degrees", json!(obs.sd)); + insert(&mut out, "landed_clean", json!(obs.lc)); + insert(&mut out, "landed_sketchy", json!(obs.lk)); insert(&mut out, "bail_seq", json!(obs.bail_seq)); insert(&mut out, "new_trick", json!(obs.nt)); insert(&mut out, "modified_trick", json!(obs.mt)); @@ -299,7 +319,8 @@ fn name_for(world: &World, id: &str, local_id: &str) -> String { } pub(super) fn snapshot(world: &World, mods: &Mods, local_id: &str) -> (Value, Value) { - let local_obs = local(world); + let mut local_obs = local(world); + local_obs.suspended = super::player_suspended(mods); let local_name = name_for(world, local_id, local_id); let mut skaters = Map::new(); skaters.insert( @@ -338,6 +359,7 @@ mod tests { inn.insert(format!("IntentName{i:02}"), 1.0); } let obs = WireObs { + suspended: true, p: [12.5, 3.0, -40.25], v: [4.0, 0.2, -1.5], av: [0.1, 1.2, -0.3], @@ -354,6 +376,7 @@ mod tests { ts: 12, landed: 3, landed_name: "360Flip".into(), + lb: "ID_TRICK_FLIP_360_FLIP".into(), sd: 540, lc: true, lk: false, bail_seq: 1, nt: true, mt: false, @@ -378,6 +401,9 @@ mod tests { let player = lua_player(&obs, "Test"); assert_eq!(player["trick"], "360Flip"); assert_eq!(player["trick_seq"], 12); + assert_eq!(player["landed_spin_degrees"], 540); + assert_eq!(player["landed_trick_base"], "ID_TRICK_FLIP_360_FLIP"); + assert_eq!(player["landed_clean"], true); assert_eq!(player["nollie"], true); assert_eq!(player["mode"], "air"); } diff --git a/crates/skate-game/src/modding/participation.rs b/crates/skate-game/src/modding/participation.rs new file mode 100644 index 00000000..b2452396 --- /dev/null +++ b/crates/skate-game/src/modding/participation.rs @@ -0,0 +1,30 @@ +//! Reversible, owner-scoped local actor suspension; no game-mode rules. +use super::Mods; +use bevy::prelude::*; + +pub(crate) fn player_suspended(mods: &Mods) -> bool { !mods.suspended_by.is_empty() } +pub(crate) fn peer_suspended(mods: &Mods, peer: u64) -> bool { + mods.skater_remote.get(&peer).is_some_and(|o| o.suspended) +} +pub(super) fn clear(world: &mut World, mods: &mut Mods) { + mods.suspended_by.clear(); + for (entity, visibility) in std::mem::take(&mut mods.hidden_players) { + if let Some(mut v) = world.get_mut::(entity) { *v = visibility; } + } +} +pub(super) fn present(world: &mut World) { + world.resource_scope(|world, mut mods: Mut| { + if player_suspended(&mods) { + let roots: Vec<_> = world.query_filtered::>().iter(world).collect(); + for entity in roots { + if let Some(mut v) = world.get_mut::(entity) { + let original = mods.attach.as_ref().and_then(|a| a.hidden.iter().find(|(e,_)| *e==entity).map(|(_,v)| *v)).unwrap_or(*v); + mods.hidden_players.entry(entity).or_insert(original); + *v = Visibility::Hidden; + } + } + } else if mods.attach.is_none() { + clear(world, &mut mods); + } + }); +} diff --git a/crates/skate-game/src/modding/player_physics.rs b/crates/skate-game/src/modding/player_physics.rs index 64eadb3f..afe8b66f 100644 --- a/crates/skate-game/src/modding/player_physics.rs +++ b/crates/skate-game/src/modding/player_physics.rs @@ -1,92 +1,176 @@ //! Native skater contact observations and temporary, mod-owned joint overrides. +use crate::physics::{GamePhysics, SkaterRuntime}; use bevy::prelude::*; -use serde_json::{json, Value}; -use skate_core::physics::{assembly::BodySnapshot, board_step::CollisionBody, - skeleton_body::{SkeletonJoints,SkeletonDriveBatch,SkeletonDriveIdentity}}; +use serde_json::{Value, json}; +use skate_core::physics::{ + assembly::BodySnapshot, + board_step::CollisionBody, + skeleton_body::{SkeletonDriveBatch, SkeletonDriveIdentity, SkeletonJoints}, +}; use skate_mods::extensions::JointOverride; -use crate::physics::{GamePhysics,SkaterRuntime}; -pub(super) fn set(world:&mut World, owner:&str, joint:usize, options:Option) -> Result<(),String> { - let mut s=world.resource_mut::(); - if joint>=s.skeleton_joints.records.len() { return Err("unknown native joint".into()); } - if s.mod_joint_overrides.get(&joint).is_some_and(|(o,_)| o!=owner) { return Err("joint override is owned by another mod".into()); } - if let Some(options)=options { s.mod_joint_overrides.insert(joint,(owner.into(),options)); } - else { s.mod_joint_overrides.remove(&joint); } +pub(super) fn set( + world: &mut World, + owner: &str, + joint: usize, + options: Option, +) -> Result<(), String> { + let mut s = world.resource_mut::(); + if joint >= s.skeleton_joints.records.len() { + return Err("unknown native joint".into()); + } + if s.mod_joint_overrides + .get(&joint) + .is_some_and(|(o, _)| o != owner) + { + return Err("joint override is owned by another mod".into()); + } + if let Some(options) = options { + s.mod_joint_overrides.insert(joint, (owner.into(), options)); + } else { + s.mod_joint_overrides.remove(&joint); + } Ok(()) } -pub(super) fn clear(world:&mut World,owner:Option<&str>) { - if let Some(mut s)=world.get_resource_mut::() { - s.mod_joint_overrides.retain(|_,(o,_)| owner.is_some_and(|owner| o!=owner)); +pub(super) fn clear(world: &mut World, owner: Option<&str>) { + if let Some(mut s) = world.get_resource_mut::() { + s.mod_part_overrides + .retain(|_, (o, _)| owner.is_some_and(|owner| o != owner)); + s.mod_joint_overrides + .retain(|_, (o, _)| owner.is_some_and(|owner| o != owner)); + if owner.is_none() { + s.mod_contact_frame = Default::default(); + } } } -fn apply(joints:&mut SkeletonJoints, index:usize, options:&JointOverride) { - let p=&mut joints.records[index].parameters.words; - if let Some(v)=options.swing_limit {p[10]=v.to_bits();p[12]=skate_core::trigonometry::cos(v).to_bits();} - if let Some(v)=options.twist_limit {p[11]=v.to_bits();p[13]=skate_core::trigonometry::cos(v).to_bits();} - if let Some(free)=options.free_swing {p[14]=if free {4} else {1};} - if let Some(free)=options.free_twist {p[15]=if free {2} else {1};} +fn apply(joints: &mut SkeletonJoints, index: usize, options: &JointOverride) { + let p = &mut joints.records[index].parameters.words; + if let Some(v) = options.swing_limit { + p[10] = v.to_bits(); + p[12] = skate_core::trigonometry::cos(v).to_bits(); + } + if let Some(v) = options.twist_limit { + p[11] = v.to_bits(); + p[13] = skate_core::trigonometry::cos(v).to_bits(); + } + if let Some(free) = options.free_swing { + p[14] = if free { 4 } else { 1 }; + } + if let Some(free) = options.free_twist { + p[15] = if free { 2 } else { 1 }; + } } -pub(crate) fn joints(s:&SkaterRuntime) -> SkeletonJoints { - let mut result=SkeletonJoints {records:s.skeleton_joints.records}; - for (&index,(_,options)) in &s.mod_joint_overrides {apply(&mut result,index,options);} +pub(crate) fn joints(s: &SkaterRuntime) -> SkeletonJoints { + let mut result = SkeletonJoints { + records: s.skeleton_joints.records, + }; + for (&index, (_, options)) in &s.mod_joint_overrides { + apply(&mut result, index, options); + } result } -pub(crate) fn filter_drives(s:&SkaterRuntime,batch:&mut SkeletonDriveBatch) { - let disabled:Vec<_>=s.mod_joint_overrides.iter().filter(|(_,(_,o))| o.drive_enabled==Some(false)) - .map(|(&i,_)| s.skeleton_joints.records[i].child).collect(); - let rows=std::mem::take(&mut batch.rows); - let ids=std::mem::take(&mut batch.identities); - let spies=std::mem::take(&mut batch.spy); - for ((row,id),spy) in rows.into_iter().zip(ids).zip(spies) { - if matches!(id,SkeletonDriveIdentity::Bone{part,..} if disabled.contains(&part)) {continue;} - batch.rows.push(row);batch.identities.push(id);batch.spy.push(spy); +pub(crate) fn filter_drives(s: &SkaterRuntime, batch: &mut SkeletonDriveBatch) { + let disabled = disabled_drives(s, false); + let rows = std::mem::take(&mut batch.rows); + let ids = std::mem::take(&mut batch.identities); + let spies = std::mem::take(&mut batch.spy); + for ((row, id), spy) in rows.into_iter().zip(ids).zip(spies) { + if matches!(id,SkeletonDriveIdentity::Bone{part,..} if disabled.contains(&part)) { + continue; + } + batch.rows.push(row); + batch.identities.push(id); + batch.spy.push(spy); } } -fn body(id:u32,p:&GamePhysics,s:&SkaterRuntime)->Option { +fn body(id: u32, p: &GamePhysics, s: &SkaterRuntime) -> Option { match CollisionBody::from_contact_id(id) { - CollisionBody::Board(id)=>Some(p.board.bodies()[id.index()]), - CollisionBody::Attached(i)=>s.skeleton.bodies().iter().chain(s.skeleton_drives.targets.bodies.iter()).chain(p.network_proxies.bodies.iter()).nth(i).copied(), - CollisionBody::StaticWorld=>None, + CollisionBody::Board(id) => Some(p.board.bodies()[id.index()]), + CollisionBody::Attached(i) => s + .skeleton + .bodies() + .iter() + .chain(s.skeleton_drives.targets.bodies.iter()) + .chain(p.network_proxies.bodies.iter()) + .nth(i) + .copied(), + CollisionBody::StaticWorld => None, } } -fn identity(id:u32)->Value { +fn identity(id: u32, p: &GamePhysics) -> Value { match CollisionBody::from_contact_id(id) { - CollisionBody::Board(id)=>json!({"kind":"board","index":id.index()}), - CollisionBody::Attached(i) if i<26=>json!({"kind":"skater","index":i}), - CollisionBody::Attached(i)=>json!({"kind":"external","index":i}), - CollisionBody::StaticWorld=>json!({"kind":"world"}), + CollisionBody::Board(id) => json!({"kind":"board","index":id.index()}), + CollisionBody::Attached(i) if i < 26 => json!({"kind":"skater","index":i}), + CollisionBody::Attached(i) => { + if let Some((peer, part)) = p.network_proxies.actors.get(&i) { + json!({"kind":"remote","index":i,"peer":peer.to_string(),"part_kind":if *part<7 {"board"}else{"skater"},"part":if *part<7 {*part}else{*part-7}}) + } else { + json!({"kind":"external","index":i,"solid_id":p.network_proxies.solids.iter().find(|(index,_)|*index==i).map(|(_,b)|b.id.to_string())}) + } + } + CollisionBody::StaticWorld => json!({"kind":"world"}), } } -pub(super) fn snapshot(world:&World)->Value { - let p=world.resource::();let s=world.resource::(); - let dt=p.settings.step.simulation.time_step; - let mut contacts=Vec::new(); - for row in p.board.solved_contacts() { - let mut storage=*row.words();let mut count=0; - skate_core::physics::contact_feedback::spy_contact_jacobians(&mut storage,1,&mut count,p.settings.step.simulation.frequency,|id| { - body(id,p,s).map_or([0;4],|b| [b.rates.position.x.to_bits(),b.rates.position.y.to_bits(),b.rates.position.z.to_bits(),0]) - }); - if count==0 {continue;} - let v=|n:usize| -> [f32;3] {std::array::from_fn(|i|f32::from_bits(storage[n+i]))}; - let normal_force=v(16);let friction_force=v(20); - let force:[f32;3]=std::array::from_fn(|i|normal_force[i]+friction_force[i]); - if !v(12).iter().chain(force.iter()).all(|v|v.is_finite()) {continue;} - contacts.push(json!({"a":identity(storage[24]),"b":identity(storage[25]),"point":v(12),"normal":v(0), - "force":force,"normal_force":normal_force,"friction_force":friction_force,"impulse":force.map(|v|v*dt), - "static_friction":row.static_friction(),"dynamic_friction":row.dynamic_friction(),"material_tags":[storage[26]&0xffff,storage[26]>>16]})); - if contacts.len()==128 {break;} +pub(super) fn snapshot(world: &World, mods: &super::Mods) -> Value { + let p = world.resource::(); + let s = world.resource::(); + let dt = p.settings.step.simulation.time_step; + let mut contacts = s.mod_contact_frame.contacts.clone(); + for contact in &mut contacts { + for side in ["a", "b"] { + if let Some(solid_id) = contact[side]["solid_id"] + .as_str() + .and_then(|s| s.parse::().ok()) + { + { + if let Some(((owner, key), _)) = + mods.bodies.iter().find(|(_, id)| **id == solid_id) + { + contact[side]["owner"] = json!(owner); + contact[side]["key"] = json!(key); + } + } + } + } } + let animation_disabled = disabled_drives(s, false); + let possession_disabled = disabled_drives(s, true); let joints=joints(s).records.iter().enumerate().map(|(i,j)| { let w=&j.parameters.words; json!({"index":i,"name":crate::physics::skeleton_body::JOINT_NAMES[i],"parent":j.parent,"child":j.child, "swing_limit":f32::from_bits(w[10]),"twist_limit":f32::from_bits(w[11]),"free_swing":w[14]==4,"free_twist":w[15]==2, - "drive_enabled":!s.mod_joint_overrides.get(&i).is_some_and(|(_,o)|o.drive_enabled==Some(false)), + "enabled":!s.mod_joint_overrides.get(&i).is_some_and(|(_,o)|o.enabled==Some(false)), + "drive_enabled":!animation_disabled.contains(&j.child), + "possession_enabled":!possession_disabled.contains(&j.child), + "load":s.mod_contact_frame.joint_loads.get(&i), + "parameters":w,"frames":j.frames.words.to_vec(), "override_owner":s.mod_joint_overrides.get(&i).map(|(o,_)|o)}) }).collect::>(); - let parts=s.skeleton.bodies().iter().enumerate().map(|(i,b)|json!({"index":i,"position":[b.rates.position.x,b.rates.position.y,b.rates.position.z], - "velocity":[b.rates.linear_velocity.x,b.rates.linear_velocity.y,b.rates.linear_velocity.z], - "angvel":[b.rates.angular_velocity.x,b.rates.angular_velocity.y,b.rates.angular_velocity.z],"inverse_mass":b.inertia.inverse_mass})).collect::>(); - json!({"tick":p.ticks,"dt":dt,"contacts":contacts,"joints":joints,"parts":parts,"ragdoll":s.skeleton_collision.is_ragdoll,"partial_ragdoll":s.skeleton_collision.partial_ragdoll}) + let parts=s.skeleton.bodies().iter().enumerate().map(|(i,b)| { + let mut out=body_value("skater",i,b); + let joint=s.skeleton_joints.records.iter().enumerate().find(|(_,j)|j.child==i); + out["joint"]=json!(joint.map(|(i,_)|i)); + out["name"]=json!(part_name(i,s)); + out["override"]=json!(s.mod_part_overrides.get(&i).map(|(owner,options)|json!({"owner":owner,"options":options}))); + let options=s.mod_part_overrides.get(&i).map(|(_,o)|o); + let collision=&s.skeleton_collision.parts[i]; + let friction=options.and_then(|o|o.friction); + out["state_flags"]=json!(options.and_then(|o|o.motion.as_deref()).map_or(b.state_flags,motion_flags)); + out["collision_enabled"]=json!(options.and_then(|o|o.collision).unwrap_or(collision.enabled)); + out["animation_drives"]=json!(!animation_disabled.contains(&i)); + out["possession_drives"]=json!(!possession_disabled.contains(&i)); + out["material"]=json!({"static_friction":friction.unwrap_or(collision.material.static_friction),"dynamic_friction":friction.unwrap_or(collision.material.dynamic_friction)}); + out + }).collect::>(); + let board = p + .board + .bodies() + .iter() + .enumerate() + .map(|(i, b)| body_value("board", i, b)) + .collect::>(); + json!({"tick":s.mod_contact_frame.tick,"dt":dt,"contacts":contacts,"contacts_truncated":s.mod_contact_frame.truncated,"joints":joints,"parts":parts,"board":board, + "ragdoll":s.skeleton_collision.is_ragdoll,"partial_ragdoll":s.skeleton_collision.partial_ragdoll}) } #[cfg(test)] @@ -94,15 +178,631 @@ mod tests { use super::*; #[test] fn free_rotation_preserves_linear_joint_and_native_defaults() { - use skate_core::physics::{skeleton_body::SkeletonJoint,joint_records::{RetailJointParametersRaw,RetailJointFramesRaw}}; - let joint=SkeletonJoint {parent:0,child:1,parameters:RetailJointParametersRaw{words:[0;16]},frames:RetailJointFramesRaw{words:[0;20]}}; - let native=SkeletonJoints{records:[joint;22]}; - let mut modified=SkeletonJoints{records:native.records}; - apply(&mut modified,0,&JointOverride {swing_limit:Some(1.2),twist_limit:Some(0.5),free_swing:Some(true),free_twist:Some(true),drive_enabled:Some(false)}); - assert_eq!(&modified.records[0].parameters.words[..10],&native.records[0].parameters.words[..10]); - assert_eq!(modified.records[0].parameters.words[14..],[4,2]); - assert_eq!(f32::from_bits(modified.records[0].parameters.words[10]),1.2); - assert_eq!(native.records[0].parameters.words,[0;16]); - assert_eq!(modified.records[1].parameters.words,[0;16]); + use skate_core::physics::{ + joint_records::{RetailJointFramesRaw, RetailJointParametersRaw}, + skeleton_body::SkeletonJoint, + }; + let joint = SkeletonJoint { + parent: 0, + child: 1, + parameters: RetailJointParametersRaw { words: [0; 16] }, + frames: RetailJointFramesRaw { words: [0; 20] }, + }; + let native = SkeletonJoints { + records: [joint; 22], + }; + let mut modified = SkeletonJoints { + records: native.records, + }; + apply( + &mut modified, + 0, + &JointOverride { + swing_limit: Some(1.2), + twist_limit: Some(0.5), + free_swing: Some(true), + free_twist: Some(true), + drive_enabled: Some(false), + ..Default::default() + }, + ); + assert_eq!( + &modified.records[0].parameters.words[..10], + &native.records[0].parameters.words[..10] + ); + assert_eq!(modified.records[0].parameters.words[14..], [4, 2]); + assert_eq!( + f32::from_bits(modified.records[0].parameters.words[10]), + 1.2 + ); + assert_eq!(native.records[0].parameters.words, [0; 16]); + assert_eq!(modified.records[1].parameters.words, [0; 16]); } } + +fn selected_parts( + s: &SkaterRuntime, + predicate: impl Fn(&JointOverride) -> bool, +) -> std::collections::BTreeSet { + let mut selected = std::collections::BTreeSet::new(); + for (&i, (_, o)) in &s.mod_joint_overrides { + if !predicate(o) { + continue; + } + let mut branch = std::collections::BTreeSet::from([s.skeleton_joints.records[i].child]); + if o.descendants { + for _ in 0..26 { + for j in &s.skeleton_joints.records { + if branch.contains(&j.parent) { + branch.insert(j.child); + } + } + } + } + selected.extend(branch); + } + selected +} +fn disabled_drives(s: &SkaterRuntime, possession: bool) -> std::collections::BTreeSet { + let mut disabled = selected_parts(s, |o| { + if possession { + o.possession_enabled == Some(false) + } else { + o.drive_enabled == Some(false) + } + }); + disabled.extend( + s.mod_part_overrides + .iter() + .filter(|(_, (_, o))| { + if possession { + o.possession_drives == Some(false) + } else { + o.animation_drives == Some(false) + } + }) + .map(|(&i, _)| i), + ); + disabled +} +pub(crate) fn filter_possession( + s: &SkaterRuntime, + rows: &mut Vec, + start: usize, +) { + let disabled = disabled_drives(s, true); + let mut index = 0; + rows.retain(|row| { + let keep = index < start + || ![ + row.frame_a_body.reaction_index, + row.frame_b_body.reaction_index, + ] + .iter() + .any(|r| { + r.checked_sub(skate_core::physics::board_step::ATTACHED_REACTION_BASE) + .is_some_and(|i| disabled.contains(&i)) + }); + index += 1; + keep + }); +} +pub(crate) fn filter_joints( + s: &SkaterRuntime, + rows: &mut Vec, +) { + let disabled: std::collections::BTreeSet<_> = s + .mod_joint_overrides + .iter() + .filter(|(_, (_, o))| o.enabled == Some(false)) + .map(|(&i, _)| { + s.skeleton_joints.records[i].child + + skate_core::physics::board_step::ATTACHED_REACTION_BASE + }) + .collect(); + rows.retain(|r| !disabled.contains(&r.reaction_a)); +} +fn part_name(i: usize, s: &SkaterRuntime) -> String { + s.animated_skeleton + .bone_indices + .get(i) + .and_then(|i| s.animation.evaluator.frames.bone_names.get(*i)) + .cloned() + .unwrap_or_else(|| format!("PART_{i}")) +} + +fn body_value(kind: &str, index: usize, b: &BodySnapshot) -> Value { + let q = b.rates.orientation; + json!({"kind":kind,"index":index,"position":arr(b.rates.position),"rotation":[q.x,q.y,q.z,q.w], + "velocity":arr(b.rates.linear_velocity),"angvel":arr(b.rates.angular_velocity), + "inverse_mass":b.inertia.inverse_mass,"inverse_inertia":b.rates.world_inverse_inertia.columns,"state_flags":b.state_flags}) +} +fn arr(v: skate_core::math::Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} +fn vec(v: [f32; 3]) -> skate_core::math::Vector3 { + skate_core::math::Vector3::new(v[0], v[1], v[2]) +} +fn point_velocity(b: Option<&BodySnapshot>, p: [f32; 3]) -> Vec3 { + b.map_or(Vec3::ZERO, |b| { + Vec3::from_array(arr(b.rates.linear_velocity)) + + Vec3::from_array(arr(b.rates.angular_velocity)) + .cross(Vec3::from_array(p) - Vec3::from_array(arr(b.rates.position))) + }) +} +pub(super) fn impulse( + world: &mut World, + reference: &skate_mods::extensions::NativeBodyRef, + impulse: [f32; 3], + point: Option<[f32; 3]>, + angular: bool, +) -> Result<(), String> { + fn apply( + b: &mut BodySnapshot, + j: [f32; 3], + point: Option<[f32; 3]>, + angular: bool, + ) -> Result<(), String> { + if b.inertia.inverse_mass <= 0. { + return Err("native body has no dynamic mass".into()); + } + let impulse = Vec3::from_array(j); + let torque = if angular { + impulse + } else { + point.map_or(Vec3::ZERO, |p| { + (Vec3::from_array(p) - Vec3::from_array(arr(b.rates.position))).cross(impulse) + }) + }; + let basis = b.rates.world_inverse_inertia.columns; + let delta = Vec3::from_array(basis[0]) * torque.x + + Vec3::from_array(basis[1]) * torque.y + + Vec3::from_array(basis[2]) * torque.z; + if !angular { + b.rates.linear_velocity = vec((Vec3::from_array(arr(b.rates.linear_velocity)) + + impulse * b.inertia.inverse_mass) + .to_array()); + } + b.rates.angular_velocity = + vec((Vec3::from_array(arr(b.rates.angular_velocity)) + delta).to_array()); + Ok(()) + } + if reference.kind == "board" { + let mut p = world.resource_mut::(); + apply( + &mut p.board.bodies_mut()[reference.index], + impulse, + point, + angular, + ) + } else { + let mut s = world.resource_mut::(); + apply( + &mut s.skeleton.bodies_mut()[reference.index], + impulse, + point, + angular, + ) + } +} +#[derive(Default)] +pub(crate) struct ContactFrame { + pub tick: u64, + pub contacts: Vec, + pub truncated: bool, + pub joint_loads: std::collections::BTreeMap, + pairs: std::collections::BTreeMap, +} +pub(crate) fn before_solve( + p: &GamePhysics, + s: &SkaterRuntime, +) -> std::collections::BTreeMap { + let mut out = std::collections::BTreeMap::new(); + for (i, b) in p.board.bodies().iter().enumerate() { + out.insert(i as u32, *b); + } + for (i, b) in s + .skeleton + .bodies() + .iter() + .chain(s.skeleton_drives.targets.bodies.iter()) + .chain(p.network_proxies.bodies.iter()) + .enumerate() + { + out.insert(CollisionBody::Attached(i).contact_id(), *b); + } + out +} +pub(crate) fn after_solve( + p: &GamePhysics, + s: &SkaterRuntime, + before: &std::collections::BTreeMap, +) -> ContactFrame { + let dt = p.settings.step.simulation.time_step; + let mut frame = ContactFrame { + tick: p.ticks, + ..Default::default() + }; + for row in p.board.solved_contacts() { + let mut storage = *row.words(); + let mut count = 0; + skate_core::physics::contact_feedback::spy_contact_jacobians( + &mut storage, + 1, + &mut count, + p.settings.step.simulation.frequency, + |id| { + body(id, p, s).map_or([0; 4], |b| { + [ + b.rates.position.x.to_bits(), + b.rates.position.y.to_bits(), + b.rates.position.z.to_bits(), + 0, + ] + }) + }, + ); + if count == 0 { + continue; + } + let v = |n: usize| -> [f32; 3] { std::array::from_fn(|i| f32::from_bits(storage[n + i])) }; + let a = storage[24]; + let b = storage[25]; + let point = v(12); + let normal = v(0); + let normal_force = v(16); + let friction_force = v(20); + let force: [f32; 3] = std::array::from_fn(|i| normal_force[i] + friction_force[i]); + let relative = + point_velocity(before.get(&a), point) - point_velocity(before.get(&b), point); + if !point.iter().chain(force.iter()).all(|v| v.is_finite()) || !relative.is_finite() { + continue; + } + // Stable body-pair identity. All manifold points share this lifecycle. + let stable = |id| { + let b = identity(id, p); + if b["kind"] == "remote" { + format!("peer:{}:{}:{}", b["peer"], b["part_kind"], b["part"]) + } else if let Some(solid) = b["solid_id"].as_str() { + format!("solid:{solid}") + } else { + format!("native:{id}") + } + }; + let mut keys = [stable(a), stable(b)]; + keys.sort(); + let key = keys.join("/"); + let phase = if s.mod_contact_frame.pairs.contains_key(&key) { + "stay" + } else { + "begin" + }; + let value = json!({"id":key,"phase":phase,"a":identity(a,p),"b":identity(b,p),"point":point,"normal":normal, + "relative_velocity_before_solve":relative.to_array(),"closing_speed":(-relative.dot(Vec3::from_array(normal))).max(0.), + "force":force,"normal_force":normal_force,"friction_force":friction_force,"impulse":force.map(|v|v*dt), + "static_friction":row.static_friction(),"dynamic_friction":row.dynamic_friction(),"material_tags":[storage[26]&0xffff,storage[26]>>16]}); + frame.pairs.insert(key, value.clone()); + if frame.contacts.len() < 128 { + frame.contacts.push(value); + } else { + frame.truncated = true; + } + } + for (key, value) in &s.mod_contact_frame.pairs { + if !frame.pairs.contains_key(key) { + let mut v = value.clone(); + v["phase"] = json!("end"); + v["force"] = json!([0, 0, 0]); + v["normal_force"] = json!([0, 0, 0]); + v["friction_force"] = json!([0, 0, 0]); + v["impulse"] = json!([0, 0, 0]); + v["closing_speed"] = json!(0); + v["relative_velocity_before_solve"] = json!([0, 0, 0]); + if frame.contacts.len() < 256 { + frame.contacts.push(v); + } else { + frame.truncated = true; + } + } + } + frame +} + +pub(crate) fn joint_loads( + s: &SkaterRuntime, + rows: &[skate_core::physics::solver::JointConstraint], + dt: f32, +) -> std::collections::BTreeMap { + let mut out = std::collections::BTreeMap::new(); + for row in rows { + let Some(child) = row + .reaction_a + .checked_sub(skate_core::physics::board_step::ATTACHED_REACTION_BASE) + else { + continue; + }; + let Some(index) = s + .skeleton_joints + .records + .iter() + .position(|j| j.child == child) + else { + continue; + }; + let w = &row.jacobian.words; + // Joint solve accumulators at bytes32/48 and projection axes192/240. + // They produce displacement corrections; divide by dt for impulse. + let project = |acc: usize, axes: usize| -> Vec3 { + (0..3).fold(Vec3::ZERO, |v, i| { + v + Vec3::new( + f32::from_bits(w[axes + i * 4]), + f32::from_bits(w[axes + i * 4 + 1]), + f32::from_bits(w[axes + i * 4 + 2]), + ) * f32::from_bits(w[acc + i]) + }) + }; + let linear = project(8, 48) / dt; + let angular = project(12, 60) / dt; + if linear.is_finite() && angular.is_finite() { + out.insert(index,json!({"linear_impulse":linear.to_array(),"angular_impulse":angular.to_array(),"force":(linear/dt).to_array(),"torque":(angular/dt).to_array(),"solver_words":w.to_vec()})); + } + } + out +} + +pub(super) fn set_part( + world: &mut World, + owner: &str, + index: usize, + options: Option, +) -> Result<(), String> { + let mut s = world.resource_mut::(); + if index >= s.skeleton.bodies().len() { + return Err("unknown physical part".into()); + } + if s.mod_part_overrides + .get(&index) + .is_some_and(|(o, _)| o != owner) + { + return Err("part is owned by another mod".into()); + } + if let Some(options) = options { + s.mod_part_overrides.insert(index, (owner.into(), options)); + } else { + s.mod_part_overrides.remove(&index); + } + Ok(()) +} +pub(crate) struct PartRestore( + Vec<( + usize, + u32, + skate_core::physics::skeleton_body::SkeletonPartCollision, + )>, +); +pub(crate) fn apply_parts(s: &mut SkaterRuntime) -> PartRestore { + let mut restore = Vec::new(); + for (&i, (_, o)) in &s.mod_part_overrides { + restore.push(( + i, + s.skeleton.bodies()[i].state_flags, + s.skeleton_collision.parts[i], + )); + if let Some(motion) = &o.motion { + s.skeleton.bodies_mut()[i].state_flags = motion_flags(motion); + } + let c = &mut s.skeleton_collision.parts[i]; + if let Some(enabled) = o.collision { + c.enabled = enabled; + } + if let Some(friction) = o.friction { + c.material.static_friction = friction; + c.material.dynamic_friction = friction; + } + } + PartRestore(restore) +} +impl PartRestore { + pub(crate) fn restore(self, s: &mut SkaterRuntime) { + for (i, flags, collision) in self.0 { + s.skeleton.bodies_mut()[i].state_flags = flags; + s.skeleton_collision.parts[i] = collision; + } + } +} + +fn motion_flags(motion: &str) -> u32 { + match motion { + "dynamic" => 4, + "frozen" => 2, + _ => 1, + } +} + +#[cfg(test)] +mod integration_tests { + use super::*; + #[test] + #[ignore = "requires SKATE3_ASSET_ROOT; headless native rig integration"] + fn authored_rig_overrides_release_drives_preserve_anchors_and_restore() { + let root = + std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").expect("asset root")); + let assets = skate_data::GameAssets::load(&root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(&root, &assets).unwrap(); + let physics = GamePhysics::load(&root).unwrap(); + let mut s = SkaterRuntime::load(&root, &graphs, &physics, "normal").unwrap(); + let index = crate::physics::skeleton_body::JOINT_NAMES + .iter() + .position(|n| n.contains("LEFT") && n.contains("FOREARM")) + .expect("native arm joint"); + let original = s.skeleton_joints.records[index]; + s.mod_joint_overrides.insert( + index, + ( + "test".into(), + JointOverride { + free_swing: Some(true), + free_twist: Some(true), + drive_enabled: Some(false), + possession_enabled: Some(false), + descendants: true, + ..Default::default() + }, + ), + ); + let changed = joints(&s); + assert_eq!(changed.records[index].frames.words, original.frames.words); + assert_eq!( + &changed.records[index].parameters.words[..10], + &original.parameters.words[..10] + ); + let disabled = disabled_drives(&s, false); + assert!(disabled.contains(&original.child)); + assert!(!disabled.contains(&original.parent)); + assert_eq!(disabled, disabled_drives(&s, true)); + let dt = physics.settings.step.simulation.time_step; + let mut batch = s.skeleton_drives.build( + s.skeleton.bodies(), + skate_core::physics::board_step::ATTACHED_REACTION_BASE, + skate_core::physics::board_step::ATTACHED_REACTION_BASE + 26, + dt, + ); + let expected = batch + .identities + .iter() + .filter( + |id| !matches!(id,SkeletonDriveIdentity::Bone{part,..} if disabled.contains(part)), + ) + .count(); + filter_drives(&s, &mut batch); + assert_eq!(batch.rows.len(), expected); + assert_eq!(batch.rows.len(), batch.identities.len()); + assert_eq!(batch.rows.len(), batch.spy.len()); + let part = original.child; + let flags = s.skeleton.bodies()[part].state_flags; + let collision = s.skeleton_collision.parts[part]; + s.mod_part_overrides.insert( + part, + ( + "test".into(), + skate_mods::extensions::PartOverride { + motion: Some("frozen".into()), + collision: Some(false), + friction: Some(0.1), + ..Default::default() + }, + ), + ); + let restore = apply_parts(&mut s); + assert_eq!(s.skeleton.bodies()[part].state_flags, 2); + assert!(!s.skeleton_collision.parts[part].enabled); + assert_eq!( + s.skeleton_collision.parts[part].material.dynamic_friction, + 0.1 + ); + restore.restore(&mut s); + assert_eq!(s.skeleton.bodies()[part].state_flags, flags); + assert_eq!(s.skeleton_collision.parts[part].enabled, collision.enabled); + assert_eq!( + s.skeleton_collision.parts[part].material.dynamic_friction, + collision.material.dynamic_friction + ); + let mut body = s.skeleton.bodies()[part]; + body.rates.position = vec([0., 0., 0.]); + body.rates.linear_velocity = vec([1., 0., 0.]); + body.rates.angular_velocity = vec([0., 0., 2.]); + assert_eq!( + point_velocity(Some(&body), [0., 1., 0.]), + Vec3::new(-1., 0., 0.) + ); + // Check exposed joint impulses against the actual native solver reactions. + let base = skate_core::physics::board_step::ATTACHED_REACTION_BASE; + let mut probe = *s.skeleton.bodies(); + probe[part].rates.linear_velocity = vec([5., -3., 2.]); + let mut rows = s.skeleton_joints.build(&probe, base, dt); + rows.retain(|r| r.reaction_a == base + part); + assert_eq!(rows.len(), 1); + let mut reactions = + vec![skate_core::physics::rigid_body::RetailReactionCorrections::default(); base + 26]; + skate_core::physics::solver::solve_constraints( + &mut [], + &mut rows, + &mut [], + &mut reactions, + 8, + ); + let loads = joint_loads(&s, &rows, dt); + let load = &loads[&index]; + let reported: [f32; 3] = serde_json::from_value(load["linear_impulse"].clone()).unwrap(); + let expected = Vec3::from_array(arr(reactions[base + part].linear_displacement)) / dt; + assert!(expected.length() > 0.001); + assert!( + (Vec3::from_array(reported) * probe[part].inertia.inverse_mass - expected).length() + < 0.001 + ); + + let initial = s.skeleton.bodies()[part].rates.linear_velocity; + let inverse_mass = s.skeleton.bodies()[part].inertia.inverse_mass; + let mut world = World::new(); + world.insert_resource(s); + world.insert_resource(physics); + impulse( + &mut world, + &skate_mods::extensions::NativeBodyRef { + kind: "skater".into(), + index: part, + }, + [2., 0., 0.], + None, + false, + ) + .unwrap(); + assert!( + (world.resource::().skeleton.bodies()[part] + .rates + .linear_velocity + .x + - initial.x + - 2. * inverse_mass) + .abs() + < 0.0001 + ); + assert!(set(&mut world, "other", index, None).is_err()); + assert!(set_part(&mut world, "other", part, None).is_err()); + clear(&mut world, Some("other")); + assert_eq!( + world.resource::().mod_joint_overrides.len(), + 1 + ); + clear(&mut world, Some("test")); + assert!( + world + .resource::() + .mod_joint_overrides + .is_empty() + ); + assert!( + world + .resource::() + .mod_part_overrides + .is_empty() + ); + } +} + +/// Effective volume properties outside the temporary solver override scope. +pub(crate) fn collision_parts( + s: &SkaterRuntime, +) -> [skate_core::physics::skeleton_body::SkeletonPartCollision; 26] { + let mut parts = s.skeleton_collision.parts; + for (&i, (_, o)) in &s.mod_part_overrides { + if let Some(enabled) = o.collision { + parts[i].enabled = enabled; + } + if let Some(friction) = o.friction { + parts[i].material.static_friction = friction; + parts[i].material.dynamic_friction = friction; + } + } + parts +} diff --git a/crates/skate-game/src/modding/session.rs b/crates/skate-game/src/modding/session.rs index 60055940..809daa5e 100644 --- a/crates/skate-game/src/modding/session.rs +++ b/crates/skate-game/src/modding/session.rs @@ -63,7 +63,7 @@ pub(super) fn net_identity(world: &World) -> (bool, String, bool, String) { match world.get_resource::() { Some(net) if net.active() => { let (active, id, host) = net.mod_identity(); - (active, id.to_string(), host, net.host_peer().to_string()) + (active, id.to_string(), host, net.host_actor().to_string()) } _ => (false, "0".into(), true, "0".into()), } diff --git a/crates/skate-game/src/multiplayer.rs b/crates/skate-game/src/multiplayer.rs index f22f524b..8abb430b 100644 --- a/crates/skate-game/src/multiplayer.rs +++ b/crates/skate-game/src/multiplayer.rs @@ -4,6 +4,7 @@ pub(crate) mod appearance; mod appearance_transfer; mod transport; mod nametags; +mod hud; use crate::{ app::SimulationSet, physics::{GamePhysics, SkaterRuntime, network}, @@ -124,8 +125,8 @@ impl Multiplayer { pub(crate) fn session_identity(&self) -> Option<(u64, u64, u64)> { self.lobby.as_ref().map(|l| (l.session, l.local, l.host_peer())) } - pub(crate) fn host_peer(&self) -> u64 { - self.lobby.as_ref().map_or(0, |l| l.host_peer()) + pub(crate) fn host_actor(&self) -> u64 { + self.lobby.as_ref().and_then(|l| l.host_actor()).unwrap_or(0) } pub(crate) fn published_name(&self) -> String { sanitize_name(&self.player_name) @@ -401,8 +402,8 @@ impl Plugin for MultiplayerPlugin { } app.insert_resource(net) .add_systems(PreUpdate, (world_changed, receive, sync_names).chain().after(crate::map_transition::MapTransitionSet)) - .add_systems(Startup, setup_hud) - .add_systems(Update, hud) + .add_systems(Startup, hud::setup) + .add_systems(Update, hud::draw) .add_systems(Update, send_pose) .add_systems( Update, @@ -581,7 +582,8 @@ fn receive(mut net: ResMut) { continue; }; if remote.roots.back().is_some_and(|p| { - Vec3::from_array(p.pose.p).distance(Vec3::from_array(state.root.p)) > 10. + skate_net::prediction::is_discontinuity(&remote.body, &state, + (revision.state.captured as f64 / 1000. - p.captured) as f32) }) { remote.roots.clear(); remote.poses.clear(); @@ -664,69 +666,28 @@ fn receive(mut net: ResMut) { net.last_metrics = Instant::now(); } } -pub(crate) fn prepare(net: Res, mut physics: ResMut, skater: Res) { +pub(crate) fn prepare(net: Res, mut physics: ResMut, skater: Res, mods: Option>) { physics.network_active = net.active(); physics.network_contacts = 0; let mut proxies = std::mem::take(&mut physics.network_proxies); proxies.bodies.clear(); proxies.volumes.clear(); - proxies.solids.clear();proxies.groups.clear(); + proxies.solids.clear();proxies.groups.clear();proxies.actors.clear(); proxies.dynamics_before.clear();proxies.dynamics_deltas.clear(); - for remote in net.remotes.values() { - if remote.body_at.elapsed() < Duration::from_millis(500) { + for (peer, remote) in &net.remotes { + if mods.as_ref().is_some_and(|m| crate::modding::peer_suspended(m, *peer)) { continue; } + if let Some(prediction) = skate_net::prediction::CollisionPrediction::at(remote.body_at.elapsed().as_secs_f32()) { proxies.append( - &remote.body, + *peer, &remote.body, &net.schema, &physics, &skater, - remote.body_at.elapsed().as_secs_f32().min(0.05), + prediction, ); } } physics.network_proxies = proxies; } -#[derive(Component)] -struct NetworkHud; -fn setup_hud(mut commands: Commands) { - commands.spawn(( - NetworkHud, - Text::new(""), - TextFont { - font_size: 15., - ..default() - }, - TextColor(Color::WHITE), - GlobalZIndex(5), - Node { - position_type: PositionType::Absolute, - top: px(8), - left: px(8), - padding: UiRect::all(px(6)), - ..default() - }, - BackgroundColor(Color::srgba(0., 0., 0., 0.6)), - )); -} -fn hud( - net: Res, - physics: Res, - mut label: Single<&mut Text, With>, - appearances: Res, -) { - ***label = if net.active() { - format!( - "{}\n{}\n{}\n{}\n{} {}\nPlayer contacts: {} | Esc > Multiplayer", - net.status, - net.rates, - net.provider_metrics, - net.visual_status, - appearances.progress, appearances.status, - physics.network_contacts - ) - } else { - String::new() - }; -} fn send(mut net: ResMut, physics: Res, skater: Res, mods:Option>) { if !net.active() || skater.pose_generation == 0 { return; @@ -738,6 +699,7 @@ fn send(mut net: ResMut, physics: Res, skater: Res String { } } -fn sync_names(mut net: ResMut) { +fn sync_names(mut net: ResMut, mut ping_sent: Local>) { let name = net.published_name(); if let Some(local) = net.lobby.as_ref().map(|l| l.local) { net.names.insert(local, name.clone()); } if net.active() { let _ = net.publish_application(NAME_KEY, name.into_bytes()); + // Each player advertises their measured host RTT through the existing + // actor-owned metadata stream, so clients can display the whole roster. + if ping_sent.is_none_or(|sent| sent.elapsed() >= Duration::from_secs(1)) { + let ping = net.lobby.as_ref().and_then(|l| if l.is_host() { Some(0) } + else { (l.stats.rtt_ms > 0).then_some(l.stats.rtt_ms) }); + if let Some(ping) = ping { + let _ = net.publish_application(hud::PING_KEY, ping.to_le_bytes().to_vec()); + } + *ping_sent = Some(Instant::now()); + } let records = net.application_records(); for (peer, key, _, bytes) in records { if key != NAME_KEY { @@ -822,3 +794,12 @@ fn sync_names(mut net: ResMut) { net.names.retain(|id, _| live.contains(id)); } } + +impl Multiplayer { + pub(crate) fn debug_sections(&self) -> [String; 3] { + [format!("CONNECTION\n{}\n{}", self.status, self.diagnostic_summary()), + format!("TRAFFIC & TRANSPORT\n{}\n{}", if self.rates.is_empty() { "No traffic samples yet" } else { &self.rates }, + if self.provider_metrics.is_empty() { "No provider metrics" } else { &self.provider_metrics }), + format!("INTERPOLATION\n{}", if self.visual_status.is_empty() { "No remote playback samples" } else { &self.visual_status })] + } +} diff --git a/crates/skate-game/src/multiplayer/appearance.rs b/crates/skate-game/src/multiplayer/appearance.rs index 9d60cb4c..c4c89b71 100644 --- a/crates/skate-game/src/multiplayer/appearance.rs +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -21,11 +21,11 @@ pub(super) enum Look { #[derive(Component)] pub(crate) struct RemoteCharacter; #[derive(Resource, Default)] -pub(super) struct Appearances { +pub(crate) struct Appearances { exchange: Exchange, connection: Option<(u64, u64)>, local: String, - pub looks: BTreeMap, + pub(super) looks: BTreeMap, seen: BTreeMap, cached: BTreeMap<[u8; 32], Look>, pub status: String, diff --git a/crates/skate-game/src/multiplayer/hud.rs b/crates/skate-game/src/multiplayer/hud.rs new file mode 100644 index 00000000..dd14dbbf --- /dev/null +++ b/crates/skate-game/src/multiplayer/hud.rs @@ -0,0 +1,101 @@ +//! Compact gameplay roster. Transport diagnostics remain in logs and menus. +use super::Multiplayer; +use bevy::prelude::*; + +pub(super) const PING_KEY: &str = "mp:ping"; +#[derive(Component)] +pub(super) struct RosterRoot; +#[derive(Component)] +pub(super) struct RosterRow(usize); +#[derive(Component)] +pub(super) struct RosterText { row: usize, ping: bool } +#[derive(Component)] +pub(super) struct RosterTitle; + +pub(super) fn setup(mut commands: Commands) { + commands.spawn((RosterRoot, GlobalZIndex(5), bevy::ui::FocusPolicy::Pass, + Node { position_type: PositionType::Absolute, right: px(24), bottom: px(24), + width: px(310), max_width: percent(48), flex_direction: FlexDirection::Column, + padding: UiRect::all(px(8)), row_gap: px(3), display: Display::None, ..default() }, + BackgroundColor(Color::srgba(0.035, 0.065, 0.10, 0.88)), + )).with_children(|panel| { + panel.spawn((RosterTitle, Text::new("FREESKATE"), + TextFont { font_size: 14., ..default() }, TextColor(Color::srgb(0.55, 0.81, 1.)), + Node { margin: UiRect::bottom(px(5)), ..default() })); + for row in 0..10 { + panel.spawn((RosterRow(row), Node { width: percent(100), min_height: px(30), + align_items: AlignItems::Center, padding: UiRect::axes(px(8), px(4)), + column_gap: px(8), display: Display::None, ..default() }, + BackgroundColor(Color::srgba(0.16, 0.24, 0.34, 0.72)), + )).with_children(|line| { + line.spawn((RosterText { row, ping: false }, Text::new(""), + TextFont { font_size: 18., ..default() }, TextColor(Color::WHITE), + Node { flex_grow: 1., min_width: px(0), ..default() })); + line.spawn((RosterText { row, ping: true }, Text::new(""), + TextFont { font_size: 16., ..default() }, TextColor(Color::srgb(0.69, 0.79, 0.88)), + TextLayout::new_with_justify(Justify::Right), + Node { width: px(70), flex_shrink: 0., ..default() })); + }); + } + }); +} +fn remote_ping(bytes: &[u8]) -> Option { + let value = u64::from_le_bytes(bytes.try_into().ok()?); + (value <= 60_000).then_some(value) +} +pub(super) fn draw( + net: Res, windows: Query<&Window>, + mut nodes: Query<(&mut Node, Option<&RosterRoot>, Option<&RosterRow>, Option<&RosterText>), Or<(With, With, With)>>, + mut labels: Query<(&mut Text, &mut TextFont, Option<&RosterText>, Option<&RosterTitle>), Or<(With, With)>>, +) { + let scale = windows.iter().next().map_or(1., |w| (w.height() / 1080.).clamp(0.65, 2.)); + let mut roster = Vec::new(); + if let Some(lobby) = &net.lobby { + let mut ids = net.player_ids(); + ids.sort_by_key(|id| (*id != lobby.local, *id)); + for id in ids.into_iter().take(10) { + let name = if id == lobby.local { net.published_name() } + else { net.names.get(&id).cloned().unwrap_or_else(|| "Player".into()) }; + // Ping is each player's RTT to the host. A host has no network hop. + let ping = if id == lobby.local { + if lobby.is_host() { Some(0) } else { (lobby.stats.rtt_ms > 0).then_some(lobby.stats.rtt_ms) } + } + else { lobby.actors.get(&id).and_then(|a| a.application.get(PING_KEY)) + .and_then(|record| remote_ping(&record.value)) }; + roster.push((name, ping.map_or_else(|| "--".into(), |ms| format!("{ms} ms")))); + } + } + for (mut node, root, row, field) in &mut nodes { + if root.is_some() { + node.display = if roster.is_empty() { Display::None } else { Display::Flex }; + node.right = px(24. * scale); node.bottom = px(24. * scale); + node.width = px(310. * scale); node.padding = UiRect::all(px(8. * scale)); + node.row_gap = px(3. * scale); + } else if let Some(row) = row { + node.display = if row.0 < roster.len() { Display::Flex } else { Display::None }; + node.min_height = px(30. * scale); + node.padding = UiRect::axes(px(8. * scale), px(4. * scale)); + node.column_gap = px(8. * scale); + } else if field.is_some_and(|f| f.ping) { + node.width = px(70. * scale); + } + } + for (mut text, mut font, field, title) in &mut labels { + if let Some(field) = field { + font.font_size = if field.ping { 16. } else { 18. } * scale; + text.0 = roster.get(field.row).map(|(name, ping)| if field.ping { ping.clone() } else { name.clone() }).unwrap_or_default(); + } else if title.is_some() { font.font_size = 14. * scale; } + } +} +#[cfg(test)] +mod tests { + use super::remote_ping; + #[test] + fn roster_ping_rejects_missing_or_malformed_peer_metadata() { + assert_eq!(remote_ping(&55u64.to_le_bytes()), Some(55)); + assert_eq!(remote_ping(&0u64.to_le_bytes()), Some(0)); // host + for bytes in [vec![], vec![1; 7], u64::MAX.to_le_bytes().to_vec()] { + assert_eq!(remote_ping(&bytes), None); + } + } +} diff --git a/crates/skate-game/src/multiplayer/nametags.rs b/crates/skate-game/src/multiplayer/nametags.rs index ba971da2..fb0d0a38 100644 --- a/crates/skate-game/src/multiplayer/nametags.rs +++ b/crates/skate-game/src/multiplayer/nametags.rs @@ -9,6 +9,7 @@ pub(crate) struct NameTag(u64); pub(super) fn draw( mut commands: Commands, net: Res, + mods: Option>, cameras: Query<(&Camera, &Transform), With>, outputs: Query<(Entity, &Camera), With>, actors: Query<(&NetworkActor, &Transform)>, @@ -21,6 +22,7 @@ pub(super) fn draw( if let (Some(view), Some(screen)) = (camera.logical_viewport_size(), output_camera.logical_viewport_size()) { if view.x > 0. && view.y > 0. { for (actor, actor_pose) in &actors { + if mods.as_ref().is_some_and(|m|crate::modding::peer_suspended(m,actor.0)) {continue;} let point = actor_pose.translation + Vec3::Y * 2.25; if point.distance(pose.translation) > 120. { continue; } if let Ok(pixel) = camera.world_to_viewport(&GlobalTransform::from(*pose), point) { diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index f58b3674..e89ec713 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -22,6 +22,8 @@ use skate_net::interpolation::{Buffer, Clock, position}; pub(super) struct RemoteSkins { reported: f64, actors: BTreeMap, + contact_solids: Vec, + contact_at: Option, rest: Vec, parents: Vec, board: Option, @@ -39,6 +41,8 @@ struct RemoteSkin { poses: Buffer>, clock: Clock, epoch: u64, + contact_parts: Vec, + contact_enabled: u64, } pub(super) struct RemoteRenderPlugin; impl Plugin for RemoteRenderPlugin { @@ -91,6 +95,8 @@ impl Plugin for RemoteRenderPlugin { board, reported: 0., actors: BTreeMap::new(), + contact_solids: Vec::new(), + contact_at: None, rest, parents, }) @@ -428,6 +434,8 @@ fn present( ) { let basis = Mat4::from_cols(Vec4::X, -Vec4::Z, Vec4::Y, Vec4::W); let now = net.started.elapsed().as_secs_f64(); + let solids = mods.as_ref().map_or_else(Vec::new, |m| crate::modding::bridge::visual_solids(m)); + let continuous = skins.contact_at.is_some_and(|last| now >= last && now - last <= 0.1); for (&id, remote) in &net.remotes { let Some(mut skin) = skins.actors.remove(&id) else { continue; @@ -438,6 +446,7 @@ fn present( skin.poses = Buffer::default(); skin.clock = Clock::for_connection(net.loopback); skin.epoch = remote.epoch; + skin.contact_parts.clear(); } for sample in &remote.roots { if skin @@ -493,6 +502,23 @@ fn present( if let Some(p) = position(&skin.positions, time) { transform.translation = Vec3::from_array(p); } + let seated = remote.body.enabled & (1u64 << 62) != 0; + let attached = mods.as_ref().is_some_and(|m| crate::modding::replication::attached_root(m, id).is_some()); + let suspended = mods.as_ref().is_some_and(|m| crate::modding::peer_suspended(m, id)); + if !continuous || skin.contact_enabled != remote.body.enabled { + skin.contact_parts.clear(); + } + skin.contact_enabled = remote.body.enabled; + if !seated && !attached && !suspended && !solids.is_empty() { + let mut parts = net.schema.visual_colliders(&remote.body, transform.to_matrix()); + let offset = skate_dynamics::visual_contact::resolve( + &parts, &skin.contact_parts, &solids, &skins.contact_solids); + transform.translation += Vec3::from_array(offset.to_array()); + for part in &mut parts { part.pose.translation += offset; } + skin.contact_parts = parts; + } else { + skin.contact_parts.clear(); + } if let Ok(mut t) = nodes.get_mut(root) { *t = transform; } @@ -511,7 +537,7 @@ fn present( if let Some(root)=skin.root { if let Some((transform,_))=attached { if let Ok(mut t)=nodes.get_mut(root) {*t=transform;} } if let Ok(mut v)=visibility.get_mut(root) { - *v=if attached.is_some_and(|(_,hidden)|hidden) {Visibility::Hidden} else {Visibility::Inherited}; + *v=if attached.is_some_and(|(_,hidden)|hidden) || mods.as_ref().is_some_and(|m|crate::modding::peer_suspended(m,id)) {Visibility::Hidden} else {Visibility::Inherited}; } } let seated = remote.body.enabled & (1u64 << 62) != 0; @@ -524,6 +550,8 @@ fn present( } skins.actors.insert(id, skin); } + skins.contact_solids = solids; + skins.contact_at = Some(now); if now < skins.reported || now - skins.reported >= 1. { let delay = skins .actors diff --git a/crates/skate-game/src/performance.rs b/crates/skate-game/src/performance.rs index 9ce12e9f..81afd11c 100644 --- a/crates/skate-game/src/performance.rs +++ b/crates/skate-game/src/performance.rs @@ -168,11 +168,19 @@ impl Plugin for PerformancePlugin { app.insert_resource(Performance::new(output)) .init_resource::() .insert_resource(phases.clone()) - // Per-pass GPU timings, which is the only way to attribute frame cost - // to the GPU rather than to render-schedule CPU work. - .add_plugins(bevy::render::diagnostic::RenderDiagnosticsPlugin) .add_systems(First, frame_begin) .add_systems(Last, frame_end); + // GPU timestamp/statistics queries add work of their own. Ordinary CPU + // frame comparisons must not enable them implicitly. + if std::env::var("SKATE_PERF_GPU").as_deref() == Ok("1") { + app.add_plugins(bevy::render::diagnostic::RenderDiagnosticsPlugin); + } + + // Phase-boundary systems serialize otherwise overlapping render work. + // Only enable that detailed attribution when explicitly requested. + if std::env::var("SKATE_PERF_RENDER").as_deref() != Ok("1") { + return; + } use bevy::render::{Render, RenderApp, RenderSystems}; let Some(render) = app.get_sub_app_mut(RenderApp) else { @@ -332,6 +340,8 @@ fn frame_end( struct Report { frames: usize, fps: f32, + fps_1_percent_low: f32, + slowest_1_percent_ms_mean: f32, frame_ms_mean: f32, frame_ms_median: f32, frame_ms_p95: f32, @@ -370,9 +380,16 @@ fn summarise(samples: &[Frame], draws: &DrawStats) -> Report { sorted[rank.clamp(1, sorted.len()) - 1] }; let frame_ms_mean = mean(&sorted); + // A 1% low averages the slowest ceil(N / 100) frame times, then takes + // their reciprocal. It is distinct from the reciprocal of the p99 cutoff. + let slowest_1_percent_ms_mean = mean(&sorted[frames - frames.div_ceil(100)..]); Report { frames, fps: if frame_ms_mean > 0.0 { 1000.0 / frame_ms_mean } else { 0.0 }, + fps_1_percent_low: if slowest_1_percent_ms_mean > 0.0 { + 1000.0 / slowest_1_percent_ms_mean + } else { 0.0 }, + slowest_1_percent_ms_mean, frame_ms_mean, frame_ms_median: percentile(0.50), frame_ms_p95: percentile(0.95), @@ -392,7 +409,11 @@ fn summarise(samples: &[Frame], draws: &DrawStats) -> Report { fn write_report(path: &std::path::Path, report: &Report) -> std::io::Result<()> { let json = serde_json::json!({ "frames": report.frames, + "gpu_queries_enabled": std::env::var("SKATE_PERF_GPU").as_deref() == Ok("1"), + "render_phase_instrumentation": std::env::var("SKATE_PERF_RENDER").as_deref() == Ok("1"), "fps": report.fps, + "fps_1_percent_low": report.fps_1_percent_low, + "slowest_1_percent_ms_mean": report.slowest_1_percent_ms_mean, "frame_ms_mean": report.frame_ms_mean, "frame_ms_median": report.frame_ms_median, "frame_ms_p95": report.frame_ms_p95, @@ -472,5 +493,18 @@ mod tests { let report = summarise(&[], &DrawStats::default()); assert_eq!(report.frames, 0); assert_eq!(report.fps, 0.0); + assert_eq!(report.fps_1_percent_low, 0.0); + } + + #[test] + fn one_percent_low_averages_the_tail_including_fractional_sample_counts() { + let mut samples = vec![frame(2.0); 199]; + samples.extend([frame(10.0), frame(18.0)]); + let report = summarise(&samples, &DrawStats::default()); + assert_eq!(report.frame_ms_p99, 2.0); + assert_eq!(report.slowest_1_percent_ms_mean, 10.0); + assert_eq!(report.fps_1_percent_low, 100.0); + let one = summarise(&[frame(8.0)], &DrawStats::default()); + assert_eq!(one.fps_1_percent_low, 125.0); } } diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 1f88f565..a5bc4c76 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -35,7 +35,7 @@ mod solve; pub(crate) mod solid_contacts; pub(crate) mod network; pub(crate) use skater::SkaterRuntime; -pub(crate) use input_phase::{facing_from_visual, horizontal_spawn}; +pub(crate) use input_phase::facing_from_visual; mod animation_feedback; mod animation_feedback_settings; mod animation_input; @@ -51,6 +51,7 @@ mod grind_chromosome; mod grind_host; mod grind_materials; mod ground_animation; +mod grind_trick; mod slide_state; mod revert_state; mod ground_exit; @@ -224,11 +225,6 @@ impl GamePhysics { // Keep the board, active trick, equipment preferences and controller history. self.animation_profile.physics_mode = difficulty as u32; } - pub(crate) fn simulation_elapsed_us(&self) -> u64 { - (self.ticks as f64 * f64::from(self.settings.step.simulation.time_step) * 1_000_000.0) - as u64 - } - pub(crate) fn period(&self) -> std::time::Duration { self.clock.period() } pub(crate) fn difficulty_index(&self) -> u32 { self.animation_profile.physics_mode } @@ -248,6 +244,7 @@ impl GamePhysics { Self::load_with_terrain(asset_root, ground::Terrain::Flat) } + #[cfg(test)] pub(crate) fn load_with_terrain( asset_root: &std::path::Path, terrain: ground::Terrain, @@ -255,6 +252,7 @@ impl GamePhysics { Self::load_with_world(asset_root, terrain, None) } + #[cfg(test)] pub(crate) fn load_with_world( asset_root: &std::path::Path, terrain: ground::Terrain, @@ -263,10 +261,6 @@ impl GamePhysics { Self::load_world_difficulty(asset_root, terrain, map, crate::difficulty::Difficulty::Easy) } - pub fn load_with_map(asset_root: &std::path::Path, map: Option<&skate_data::skate_map::SkateMap>) -> Result { - Self::load_with_difficulty(asset_root, map, crate::difficulty::Difficulty::Easy) - } - pub fn load_with_difficulty(asset_root: &std::path::Path, map: Option<&skate_data::skate_map::SkateMap>, difficulty: crate::difficulty::Difficulty) -> Result { Self::load_world_difficulty(asset_root, ground::Terrain::Course, map, difficulty) } @@ -429,7 +423,7 @@ pub(crate) fn advance( } if mods .as_ref() - .is_some_and(|m| crate::modding::player_attached(m)) + .is_some_and(|m| crate::modding::player_attached(m) || crate::modding::player_suspended(m)) { return; } @@ -512,6 +506,9 @@ impl GamePhysics { grind::post(self, skater)?; } wipeout::check_after_physics(self, skater)?; + if skater.player_state.current() == skate_core::player::state::PhysicalStateId::PhysicsAirSecondary { + grind_trick::post_velocity(self, skater); + } offboard::post_physics::advance(self, skater)?; let compression = skater.skeleton_output.average_compressions(&self.board); render_pose::publish(self, skater, compression)?; diff --git a/crates/skate-game/src/physics/air_trajectory/world.rs b/crates/skate-game/src/physics/air_trajectory/world.rs index d24c694f..5c2b48ee 100644 --- a/crates/skate-game/src/physics/air_trajectory/world.rs +++ b/crates/skate-game/src/physics/air_trajectory/world.rs @@ -2,7 +2,7 @@ //!82772028, across the host's world-format boundary. BoardWorld stores static //!world-space triangles, so their source geometry transform is identity. use skate_core::{ - air::trajectory::{SurfaceHit, WorldWithoutGrindEdges}, + air::trajectory::SurfaceHit, math::Vector3, physics::{ board_world::BoardWorld, @@ -17,13 +17,6 @@ const IDENTITY: [[f32; 4]; 4] = [ [0.0; 4], ]; -///BoardWorld's authored format contains triangles only; no grind primitive -///collection is present. Update this adapter together with any world format -///extension that introduces actual grind edges. -pub(super) fn topology(_world: &BoardWorld) -> WorldWithoutGrindEdges { - WorldWithoutGrindEdges -} - pub(super) fn line( world: &BoardWorld, start: Vector, @@ -63,6 +56,16 @@ pub(super) fn line( } } } + if let Some(external) = world.external_line(start, vec3(end), radius) { + let hit = external.hit.geometry; + if hit.fraction.clamp(0.0, 1.0) < nearest { + result = Some(SurfaceHit { + position: lanes(hit.position), normal: lanes(hit.normal), + transform: external.frame, surface: external.hit.tag, + geometry: external.geometry_id, + }); + } + } Ok(result) } pub(super) fn nearby( @@ -85,6 +88,15 @@ pub(super) fn nearby( } } } + if triangles.len() < 64 { + for vertices in world.external_nearby(vec3(center), radius) { + let vertices = vertices.map(lanes); + if triangle_box(vertices, minimum, maximum) { + triangles.push(vertices); + if triangles.len() == 64 { break; } + } + } + } Ok(triangles) } ///82761698 full separating-axis triangle/AABB predicate: nine edge-axis diff --git a/crates/skate-game/src/physics/climbing/ledge.rs b/crates/skate-game/src/physics/climbing/ledge.rs index fb72710a..32d84ccf 100644 --- a/crates/skate-game/src/physics/climbing/ledge.rs +++ b/crates/skate-game/src/physics/climbing/ledge.rs @@ -20,6 +20,7 @@ fn hit(world: &BoardWorld, a: Vec3, b: Vec3, radius: f32) -> Option<(Vec3, Vec3) Vec3::new(h.normal.x, h.normal.y, h.normal.z), )) } +#[cfg(test)] pub(super) fn find(world: &BoardWorld, feet: Vec3, facing: Vec3) -> Option { find_range(world, feet, facing, 1.90, 0.95) } diff --git a/crates/skate-game/src/physics/foot_ik_queries.rs b/crates/skate-game/src/physics/foot_ik_queries.rs index 7723fdba..c8cc1fdc 100644 --- a/crates/skate-game/src/physics/foot_ik_queries.rs +++ b/crates/skate-game/src/physics/foot_ik_queries.rs @@ -132,6 +132,16 @@ fn query_trajectory( } } } + if let Some(external) = world.external_line(origin, vector(end), radius) { + let hit = external.hit.geometry; + if hit.fraction.clamp(0.0, 1.0) < nearest { + let point = lanes(hit.position); + result.position = point; + result.normal = lanes(hit.normal); + result.surface = external.hit.tag; + position = Some(point); + } + } Ok::<_, String>(position) })?; result.hit = result.collision_time >= 0.0; @@ -143,3 +153,29 @@ fn vector(v: [f32; 4]) -> Vector3 { fn lanes(v: Vector3) -> [f32; 4] { [v.x, v.y, v.z, 0.0] } + +#[cfg(test)] +mod mod_platform_tests { + use super::*; + use std::sync::Arc; + use skate_dynamics::{DynamicsWorld, BodyDesc, BodyType, Shape}; + #[test] + fn toes_and_hips_find_rapier_platform_and_follow_its_pose() { + let mut dynamics = DynamicsWorld::default(); + let id = dynamics.spawn(BodyDesc { body_type: BodyType::Kinematic, + shape: Shape::Box { half_extents: [3.,0.5,3.] }, position:[0.,40.,0.], + ..Default::default() }).unwrap(); + let mut world = BoardWorld::new(vec![]); + world.set_external_queries(Some(Arc::new(crate::modding::bridge::MovingQueries(dynamics.solid_bodies())))); + for velocity in [[0.,-1.83,0.,0.],[0.,-100.,0.,0.]] { + let hit=query_trajectory(&world,[0.,41.,0.,0.],velocity).unwrap(); + assert!(hit.hit); assert!((hit.position[1]-40.5).abs()<0.001); + assert!(hit.normal[1]>0.99); + } + assert!(!query_trajectory(&world,[8.,41.,0.,0.],[0.,-1.83,0.,0.]).unwrap().hit); + assert!(dynamics.set_pose(id,[10.,40.,0.],[0.,0.,0.,1.])); + world.set_external_queries(Some(Arc::new(crate::modding::bridge::MovingQueries(dynamics.solid_bodies())))); + assert!(!query_trajectory(&world,[0.,41.,0.,0.],[0.,-1.83,0.,0.]).unwrap().hit); + assert!(query_trajectory(&world,[10.,41.,0.,0.],[0.,-1.83,0.,0.]).unwrap().hit); + } +} diff --git a/crates/skate-game/src/physics/footplant.rs b/crates/skate-game/src/physics/footplant.rs index c8d94cea..3dea3082 100644 --- a/crates/skate-game/src/physics/footplant.rs +++ b/crates/skate-game/src/physics/footplant.rs @@ -13,7 +13,7 @@ pub(crate) type V = [f32; 4]; pub(crate) struct Footplant { pub enabled: bool, //240; FullReset and Air Enter clear this separately. pub(super) result: QueryResult, - pub(super) settings: Settings, + settings: Settings, pub selected_toe: Option, //616 signed -1 pub candidate: bool, //624 pub hit: bool, //625 diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 3b6245da..2d444eaf 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -172,6 +172,9 @@ pub(super) fn advance( super::handplant::ground_update(physics,skater)?; input_phase::update_ground(physics, skater)?; } + skate_core::player::state::PhysicalStateId::PhysicsAirSecondary => { + super::grind_trick::advance(physics, skater)? + } skate_core::player::state::PhysicalStateId::PhysicsAir => { super::air_phase::advance(physics, skater)? } diff --git a/crates/skate-game/src/physics/grind.rs b/crates/skate-game/src/physics/grind.rs index 15a305f0..aa56a7dc 100644 --- a/crates/skate-game/src/physics/grind.rs +++ b/crates/skate-game/src/physics/grind.rs @@ -1,7 +1,6 @@ //! ZIP grind runtime split into source-closed components against CURRENT APIs. //! Physical acquisition remains with the input manager. The retained runtime //! owns family state, body operations and publication, never candidate selection. -pub(crate) mod acquisition; mod advance; mod arithmetic; pub(crate) mod board; @@ -53,14 +52,4 @@ impl Family { _ => return None, }) } - pub fn physical_state(self) -> u32 { - match self { - Self::Boardslide => 400, - Self::FiftyFifty => 401, - Self::Tipslide => 402, - Self::FiveO => 403, - Self::Backslash => 404, - Self::Darkslide => 405, - } - } } diff --git a/crates/skate-game/src/physics/grind/acquisition.rs b/crates/skate-game/src/physics/grind/acquisition.rs deleted file mode 100644 index 349b6e5c..00000000 --- a/crates/skate-game/src/physics/grind/acquisition.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Coordinator boundary only. No copied ZIP query, admission or family priority. -use skate_core::physics::grind_contact::{Primitive, families::Contact}; - -/// The investigator owns admission, state priority and fallback proximity. -/// Publish this only for an admitted physical contact; a proximity-only result -/// stays with the manager and must not be converted into an active grind here. -#[derive(Clone, Copy, Debug)] -pub(crate) struct AcquiredContact { - /// Includes the manager's seven-valued entry_kind, independently of kind. - pub contact: Contact, - /// Exact winning primitive endpoints/owner, not fabricated truck endpoints. - pub primitive: Primitive, -} diff --git a/crates/skate-game/src/physics/grind/observation.rs b/crates/skate-game/src/physics/grind/observation.rs index 8a7c3ca4..e8b5dc06 100644 --- a/crates/skate-game/src/physics/grind/observation.rs +++ b/crates/skate-game/src/physics/grind/observation.rs @@ -1,6 +1,5 @@ //! Required producer inputs. None implements Default: absence of a producer is //! not permission to use world-up, metal friction, geometry zero or full energy. -use super::Family; type V = [f32; 4]; /// Snapshot after the manager's geometry, material, assistance, balance, @@ -11,7 +10,6 @@ pub(crate) struct ManagerObservation { pub surface: SurfaceObservation, pub control: ControlObservation, pub engagement: EngagementObservation, - pub jumper: JumperObservation, } #[derive(Clone, Copy, Debug)] @@ -45,34 +43,15 @@ pub(crate) struct SurfaceObservation { #[derive(Clone, Copy, Debug)] pub(crate) struct ControlObservation { - pub family: Family, /// Signed bit31 forces exit; bit29 selects the front end; bit30 delivers /// engagement velocity. Keep the source word rather than guessing booleans. pub flags_1516: u32, - pub flags_2468: u32, pub flags_2488: u32, pub translation_2796: f32, pub balance_2800: f32, - /// Original exit-lean producer's result, not derived from deck lean here. - pub exit_lean: f32, } #[derive(Clone, Copy, Debug)] pub(crate) struct EngagementObservation { - pub velocity_1184: V, pub kind_1248: u32, } - -/// Jumper cache after82D739D8; invalid investigation retains its previous -/// geometry/normals. Do not reconstruct these from the current deck frame. -#[derive(Clone, Copy, Debug)] -pub(crate) struct JumperObservation { - pub geometry_kind_16: u32, - pub family_20: Family, - pub energy_24: f32, - pub high_side_32: V, - pub normal_48: V, - pub direction_64: V, - pub upmost_normal_80: V, - pub point_96: V, -} diff --git a/crates/skate-game/src/physics/grind/runtime.rs b/crates/skate-game/src/physics/grind/runtime.rs index 83307c53..c7192774 100644 --- a/crates/skate-game/src/physics/grind/runtime.rs +++ b/crates/skate-game/src/physics/grind/runtime.rs @@ -33,9 +33,6 @@ impl Runtime { chromosome: grind_chromosome::Chromosome::uninitialized(), }) } - pub fn active_family(&self) -> Option { - self.active - } pub fn active_name(&self) -> Option<&'static str> { Some(match self.active? { Family::FiftyFifty => "50-50", diff --git a/crates/skate-game/src/physics/grind_trick.rs b/crates/skate-game/src/physics/grind_trick.rs new file mode 100644 index 00000000..26f6ab54 --- /dev/null +++ b/crates/skate-game/src/physics/grind_trick.rs @@ -0,0 +1,200 @@ +//! TU3 PHYSICS_STATE_GRIND_TRICK202, vtable82327174, owner PhysicalPlayer+1716. +//! Enter82D335F8, Exit82D33678, Update82D336F8, board82D33928, post82D33A70. +//! FillPhysOut is empty; PredictFutureOfDeck shares82D34DA8. +use super::{GamePhysics, SkaterRuntime}; +use skate_core::{ + math::Vector3, + physics::{board::BodyId, force_queue::QueuedPointForce}, + riding::{collision_response::CollisionResponsePhysical, grounded::drag::DRAG_FREQUENCY}, +}; +type V = [f32; 4]; + +#[derive(Default)] +pub(crate) struct GrindTrick { + velocity: V, //48: authored motion translated to world at60Hz + contact_free_frames: u32, //64 + released: bool, //68, sticky until Enter +} +impl GrindTrick { + fn update_contact(&mut self, flags: u32) { + if !self.released { + self.contact_free_frames = if flags & 0x4000 != 0 { + 0 + } else { + self.contact_free_frames.wrapping_add(1) + }; + self.released = self.contact_free_frames > 3; + } + } + pub fn wipeout_scale(&self) -> f32 { + if self.released { 0.5 } else { 2.0 } + } +} +pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.grind_trick = GrindTrick::default(); + skater.ground_lifecycle.skeleton_elapsed_16505 = true; + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut skater.ground_lifecycle.board_animated_290); + if skater.wipeout.state.mode != 1 { + skater.wipeout.state.mode = 1; + skater.wipeout.state.balance = 0.0; + } + Ok(()) +} +pub(super) fn exit(physics: &mut GamePhysics) { + set_drag(physics, 0.0); + physics.settings.wheel_material = physics.settings.standard_wheel_material; +} +fn set_drag(physics: &mut GamePhysics, drag: f32) { + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = drag * DRAG_FREQUENCY; + } +} +fn rotate(m: [V; 4], v: V) -> V { + std::array::from_fn(|i| m[2][i].mul_add(v[2], m[1][i].mul_add(v[1], m[0][i] * v[0]))) +} +fn xyz(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn normalize(v: V) -> V { + let len = skate_core::physics::board_motion_output::length(xyz(v)); + if len > f32::from_bits(0x358637bd) { + v.map(|x| x / len) + } else { + [0.0; 4] + } +} +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + skater.grind_trick.update_contact(p.flags_2468); + let motion = skater.animated_skeleton.motion.trajectory; + skater.grind_trick.velocity = rotate( + skater.animated_skeleton.roots.animation_to_world, + motion[3].map(|x| x * 60.0), + ); + let heading = normalize(rotate(motion, physics.riding.reckoning_frames.heading)); + physics.riding.reckoning.dynamic_up = xyz(p.vectors_464_480_496_512_528[4].map(f32::from_bits)); + physics.riding.update_ground_reckoning_with_heading( + &physics.board, + super::riding_outputs::RidingPoseInputs { + com_to_deck: xyz(p.animation_com_to_deck_752.map(f32::from_bits)), + body_spin: skater.animation_input.extra.physical_body_spin, + }, + p.flags_2468, + skater.animation_input.fields.balance, + p.flags_2476 & 0x4000_0000 != 0, + p, + heading, + ); + super::ground_animation::skeleton::advance(physics, skater)?; + skater.skeleton_output.correction.pending = true; + let p = &skater.player_input.processed; + skater.ground.steering.update( + 0.0, + skater.ground_settings.board().steering.tilt_blending, + p.flags_2468, + p.flags_2472, + ); + physics.settings.wheel_material = skater.ground_settings.wheel_material; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("GrindTrick requires current BoardToolkit")?; + let n = physics.riding.reckoning.ground_normal; + let drag = skate_core::riding::braking::calculate_linear_drag( + skate_core::riding::braking::LinearDragInput { + flags_2468: p.flags_2468, + absolute_body_speed: t.absolute_speed, + balance_2720: skater.animation_input.fields.balance, + scalar_2724: skater.ground_lifecycle.manual_drag_2724, + comparison_scalar: n.y, + }, + skater.ground_settings.board().linear_drag, + ); + if let Some(force) = + skater + .ground_runtime + .calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: p.collision_pose_error_736.map(f32::from_bits), + velocity: p.vectors_400_416[1].map(f32::from_bits), + forward: t.travel_direction, + up: p.vectors_544_560_592_608[0].map(f32::from_bits), + ground_normal: [n.x, n.y, n.z, 0.0], + time_step: p.timestep_2604, + mass: t.total_mass, + }) + { + physics.board.forces_mut().append(QueuedPointForce { + tag: 15, + force_world: xyz(force.force_2528), + point_body: xyz(force.point_2544), + }); + } else { + set_drag(physics, drag); + } + Ok(()) +} +//82D33B38..BC4 modifies only the deck body velocity after wipeout checks. +fn authored_velocity(current: V, target: V) -> V { + let direction = normalize(target); + let dot = (direction[0] * current[0] + direction[1] * current[1]) + direction[2] * current[2]; + let blend = f32::from_bits(0x3f666666); //82098F70:0.9 + let speed = f32::from_bits(0x3fb33333); //1.4 + std::array::from_fn(|i| { + let along = direction[i] * dot; + along.mul_add( + 1.0 - blend, + target[i].mul_add(blend * speed, current[i] - along), + ) + }) +} +pub(super) fn post_velocity(physics: &mut GamePhysics, skater: &SkaterRuntime) { + if skater.player_input.processed.flags_2472 & 0x8000 == 0 { + let deck = &mut physics.board.bodies_mut()[BodyId::Deck.index()]; + let v = deck.rates.linear_velocity; + deck.rates.linear_velocity = xyz(authored_velocity( + [v.x, v.y, v.z, 0.0], + skater.grind_trick.velocity, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn grind_trick_latches_after_four_contact_free_updates_and_resets_on_enter() { + let mut s = GrindTrick::default(); + for _ in 0..3 { + s.update_contact(0); + assert_eq!(s.wipeout_scale(), 2.0); + } + s.update_contact(0x4000); + for _ in 0..3 { + s.update_contact(0); + assert!(!s.released); + } + s.update_contact(0); + assert!(s.released); + assert_eq!(s.wipeout_scale(), 0.5); + s.update_contact(0x4000); + assert!(s.released); + s = GrindTrick::default(); + assert_eq!(s.wipeout_scale(), 2.0); + } + #[test] + fn grind_trick_motion_preserves_velocity_perpendicular_to_authored_direction() { + let v = authored_velocity([10.0, -3.0, 4.0, 0.0], [2.0, 0.0, 0.0, 0.0]); + assert!((v[0] - 3.52).abs() < 0.00001); + assert_eq!(&v[1..3], &[-3.0, 4.0]); + assert_eq!( + authored_velocity([10.0, -3.0, 4.0, 0.0], [0.0; 4]), + [10.0, -3.0, 4.0, 0.0] + ); + } +} diff --git a/crates/skate-game/src/physics/ground.rs b/crates/skate-game/src/physics/ground.rs index 81635a00..998dfe9b 100644 --- a/crates/skate-game/src/physics/ground.rs +++ b/crates/skate-game/src/physics/ground.rs @@ -18,16 +18,19 @@ pub(crate) const HEIGHT: f32 = -0.035; /// One authored terrain selection drives both presentation and live queries. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Terrain { + #[cfg(test)] Flat, Course, } impl Terrain { + #[cfg(test)] pub(crate) fn surfaces(self) -> [Vec<[Vector3; 4]>; 4] { match self { Self::Flat => flat_surfaces(), Self::Course => surfaces() } } pub(crate) fn world(self, material: RetailContactMaterial) -> BoardWorld { match self { + #[cfg(test)] Self::Flat => flat_world(material), Self::Course => world(material), } @@ -146,6 +149,7 @@ pub(super) fn world(material: RetailContactMaterial) -> BoardWorld { /// Deliberately authored flat-ground comparison surface. Both the solver and /// all gameplay probes query these real collision triangles. +#[cfg(test)] pub(crate) fn flat_surfaces() -> [Vec<[Vector3; 4]>; 4] { let v = Vector3::new; [ @@ -160,6 +164,7 @@ pub(crate) fn flat_surfaces() -> [Vec<[Vector3; 4]>; 4] { vec![], ] } +#[cfg(test)] pub(crate) fn flat_world(material: RetailContactMaterial) -> BoardWorld { build_world(material, flat_surfaces(), Vec::new()) } diff --git a/crates/skate-game/src/physics/ground_animation.rs b/crates/skate-game/src/physics/ground_animation.rs index 6177434b..d3db159b 100644 --- a/crates/skate-game/src/physics/ground_animation.rs +++ b/crates/skate-game/src/physics/ground_animation.rs @@ -1,7 +1,7 @@ //! GroundAnimation103 lifecycle82D33BF8/33DB8/33C70/34150, original Skate3TU3. mod board; mod settings; -mod skeleton; +pub(super) mod skeleton; use super::{GamePhysics, SkaterRuntime}; pub(crate) use settings::GroundAnimationSettings; use skate_core::{ diff --git a/crates/skate-game/src/physics/ground_animation/skeleton.rs b/crates/skate-game/src/physics/ground_animation/skeleton.rs index 67aaa387..a7a71b1b 100644 --- a/crates/skate-game/src/physics/ground_animation/skeleton.rs +++ b/crates/skate-game/src/physics/ground_animation/skeleton.rs @@ -1,7 +1,7 @@ //!82D33E00 calls SkeletonUpdateAnimated82BDDA10 with fast blending enabled. use super::*; use crate::physics::{input_phase, skeleton_input_runtime::SkeletonOwners}; -pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { +pub(in crate::physics) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { let collision = input_phase::collision(skater); let mut owners = SkeletonOwners { animated: &mut skater.animated_skeleton, diff --git a/crates/skate-game/src/physics/ground_runtime/state.rs b/crates/skate-game/src/physics/ground_runtime/state.rs index aecd56fd..6a860b81 100644 --- a/crates/skate-game/src/physics/ground_runtime/state.rs +++ b/crates/skate-game/src/physics/ground_runtime/state.rs @@ -1,7 +1,7 @@ //! Native Ground controller ownership. Source entry/update and output retain //! the same instances; constructing this does not mark Ground as entered. use super::super::animation_input::AnimationInput; -use super::{GroundControllers, GroundPumping}; +use super::GroundPumping; use skate_core::physics::board_toolkit::BoardToolkit; use skate_core::{ physics::manual::state::ManualState, @@ -91,13 +91,4 @@ impl GroundState { self.output_settings, ) } - pub fn controllers(&mut self) -> GroundControllers<'_> { - GroundControllers { - speed_wobble: &mut self.wobble, - truck_steering: &mut self.steering, - speed_model: &mut self.speed, - manual: &mut self.manual, - heading_previous: &mut self.heading_previous, - } - } } diff --git a/crates/skate-game/src/physics/input_phase.rs b/crates/skate-game/src/physics/input_phase.rs index a0e02633..c8834360 100644 --- a/crates/skate-game/src/physics/input_phase.rs +++ b/crates/skate-game/src/physics/input_phase.rs @@ -2,7 +2,7 @@ //! PlayerInput retains the original ordering; callbacks split host ownership. #[path = "input_teleport.rs"] mod teleport; -pub(crate) use teleport::{facing_from_visual, horizontal_spawn}; +pub(crate) use teleport::facing_from_visual; use super::{ GamePhysics, SkaterRuntime, animated_skeleton::AnimatedSkeleton, @@ -32,6 +32,17 @@ use skate_core::{ player::input_phase::{AnimationInputPacket, PhysicalPlayerInput, ProcessedPhysicsInput}, }; +// TU3 82DB4330 calls 825903C8: 82590414 reads action-table +0x104 +// (65 * 4), the signed left-stick Y action. The caller clamps/stores it at +// Processed+2636. Action 71 is the right grab trigger, not vert-exit lean. +fn transition_action(actions: &mut dyn ActionMap) -> f32 { + actions.value(65) +} + +#[cfg(test)] +#[path = "input_transition_tests.rs"] +mod transition_tests; + pub(super) fn advance( physics: &mut GamePhysics, skater: &mut SkaterRuntime, @@ -50,7 +61,7 @@ pub(super) fn advance( actor_query_56: 0, actor_query_44: 0, input_available, - transition_action: actions.value(71), + transition_action: transition_action(actions), published_board_transform: if skater.player_input.physical.state.flag_61 != 0 { skater .player_input @@ -229,7 +240,9 @@ impl PlayerInputCallbacks for Callbacks<'_, '_> { balance_2720: self.animation_input.fields.balance, translation_2796: extra.grind_translation, stability_nudge_2800: extra.grind_stability_nudge, + #[cfg(test)] up_down_2804: extra.grind_up_down, + #[cfg(test)] grab_min_height_2808: extra.grind_grab_min_height, }; let mut host = super::grind_host::LiveHost { diff --git a/crates/skate-game/src/physics/input_transition_tests.rs b/crates/skate-game/src/physics/input_transition_tests.rs new file mode 100644 index 00000000..97d3daf8 --- /dev/null +++ b/crates/skate-game/src/physics/input_transition_tests.rs @@ -0,0 +1,34 @@ +//! Exercise the production vert-input adapter from raw controller samples. +use super::transition_action; +use skate_core::input::{gameplay_map::GameplayActions, pad::Pad, xbox::{self, XboxState}}; + +fn sample(left_y: i16, triggers: [u8; 2], right_y: i16) -> f32 { + let mut pad = Pad::new(); + pad.update(&xbox::convert(&XboxState { + buttons: 0, triggers, left: [0, left_y], right: [0, right_y], + }, 0)); + transition_action(&mut GameplayActions::from_pad(&pad)) +} + +#[test] +fn quarter_pipe_exit_uses_signed_forward_stick() { + // Positive lean sends the launch away from the ramp's inward normal; + // neutral/backward lean retains the return-to-transition trajectory. + assert!(sample(32767, [0, 0], 0) > 0.99); + assert!(sample(-32768, [0, 0], 0) < -0.99); + assert_eq!(sample(0, [0, 0], 0), 0.0); + assert_eq!(sample(4000, [0, 0], 0), 0.0); // native stick deadzone + let partial = sample(20000, [0, 0], 0); + assert!(partial > 0.25 && partial < 0.99); +} + +#[test] +fn quarter_pipe_exit_is_independent_of_grabs_and_trick_stick() { + for triggers in [[0, 0], [255, 0], [0, 255], [255, 255]] { + for right_y in [-32768, 0, 32767] { + for left_y in [-32768, 0, 32767] { + assert_eq!(sample(left_y, triggers, right_y), sample(left_y, [0, 0], 0)); + } + } + } +} diff --git a/crates/skate-game/src/physics/network.rs b/crates/skate-game/src/physics/network.rs index 47385e44..00514d7c 100644 --- a/crates/skate-game/src/physics/network.rs +++ b/crates/skate-game/src/physics/network.rs @@ -30,9 +30,6 @@ fn xyz(v: Vector3) -> [f32; 3] { fn vector(v: [f32; 3]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } -fn add(a: Vector3, b: Vector3) -> Vector3 { - Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) -} fn body_pose(body: &BodySnapshot) -> Pose { let q = body.rates.orientation; Pose { @@ -76,7 +73,7 @@ pub(crate) fn capture_body(physics: &GamePhysics, skater: &SkaterRuntime) -> Bod } } } - for (i, part) in skater.skeleton_collision.parts.iter().enumerate() { + for (i, part) in crate::modding::player_physics::collision_parts(skater).iter().enumerate() { if part.enabled && part.volume_group == 0 { enabled |= 1 << (7 + i); } @@ -124,6 +121,18 @@ pub(crate) struct Schema { pub fingerprint: u64, } impl Schema { + /// Latest physical shape layout placed at the buffered visual root. This is + /// only for presentation clearance; the owner's simulation remains final. + pub fn visual_colliders(&self, frame: &BodyState, root: Mat4) -> Vec { + let relative = root * matrix(frame.root).inverse(); + self.volumes.iter().filter_map(|(i, volume)| { + if frame.enabled & (1u64 << i) == 0 { return None; } + let body = frame.bodies.get(*i)?; + let primitive = transform(volume.primitive, relative * matrix(body.pose)); + let (shape, pose) = super::solid_contacts::shape(primitive)?; + Some(skate_dynamics::SolidCollider { shape, pose, friction: 0. }) + }).collect() + } pub fn new(physics: &GamePhysics, skater: &SkaterRuntime) -> Result { let mut mode = skate_core::physics::skeleton_body::SkeletonCollisionMode::new_normal( skater.skeleton_collision.settings, @@ -248,17 +257,19 @@ pub(crate) struct Proxies { pub volumes: Vec, pub solids: Vec<(usize, skate_dynamics::SolidBody)>, pub groups: std::collections::BTreeMap, + pub actors: std::collections::BTreeMap, pub dynamics_before: Vec<(u64, usize, [f32; 3], [f32; 3])>, pub dynamics_deltas: Vec<(u64, [f32; 3], [f32; 3], [f32; 3], [f32; 3])>, } impl Proxies { pub fn append( &mut self, + peer: u64, frame: &BodyState, schema: &Schema, physics: &GamePhysics, skater: &SkaterRuntime, - age: f32, + prediction: skate_net::prediction::CollisionPrediction, ) { // The local native skeleton is intentionally paused during a mod // attachment. It is not a valid culling origin for a driven object. @@ -293,6 +304,7 @@ impl Proxies { }; inertia }; + let wire = prediction.body(wire); let q = Quat::from_array(wire.pose.q).normalize(); b.rates.orientation = RetailQuaternion { x: q.x, @@ -305,7 +317,7 @@ impl Proxies { }; b.rates.world_inverse_inertia = world_inverse_inertia(b.rates.basis, b.inertia.inverse_tensor); - b.rates.position = add(vector(wire.pose.p), vector(wire.velocity.map(|v| v * age))); + b.rates.position = vector(wire.pose.p); b.rates.linear_velocity = vector(wire.velocity); b.rates.angular_velocity = vector(wire.angular); b.rates.force_acceleration = Vector3::ZERO; @@ -313,6 +325,7 @@ impl Proxies { b.state_flags = 4; self.groups.insert(base + i, if i < 7 { physics.board.collision_group() } else { skater.skeleton_collision.assembly_group }); + self.actors.insert(base+i,(peer,i)); self.bodies.push(b); } for &(i, ref template) in &schema.volumes { diff --git a/crates/skate-game/src/physics/player_input/grind.rs b/crates/skate-game/src/physics/player_input/grind.rs index b83793da..b988aba7 100644 --- a/crates/skate-game/src/physics/player_input/grind.rs +++ b/crates/skate-game/src/physics/player_input/grind.rs @@ -34,7 +34,9 @@ pub(crate) struct PreContext { pub balance_2720: f32, pub translation_2796: f32, pub stability_nudge_2800: f32, + #[cfg(test)] pub up_down_2804: f32, + #[cfg(test)] pub grab_min_height_2808: f32, } @@ -165,6 +167,7 @@ impl GrindInputState { } ///82D8A638 resets the manager, not the separately constructed child objects. + #[cfg(test)] pub fn reset(&mut self) { self.previous_state = 0; self.engagement_counter = 0; diff --git a/crates/skate-game/src/physics/player_input/grind/publication.rs b/crates/skate-game/src/physics/player_input/grind/publication.rs index 9debb900..3ebdfd49 100644 --- a/crates/skate-game/src/physics/player_input/grind/publication.rs +++ b/crates/skate-game/src/physics/player_input/grind/publication.rs @@ -21,6 +21,8 @@ pub(super) fn observation( jumper: &manager::Jumper, ) -> Result { let f = &p.grind; + family(f.family_1248)?; + family(jumper.family)?; Ok(ManagerObservation { geometry: GeometryObservation { point_1120: float(f.point_1120), @@ -44,28 +46,14 @@ pub(super) fn observation( gravity_relief_1512: f.gravity_relief_1512, }, control: ControlObservation { - family: family(f.family_1248)?, flags_1516: f.flags_1516, - flags_2468: p.flags_2468, flags_2488: p.flags_2488, translation_2796: c.translation_2796, balance_2800: c.stability_nudge_2800, - exit_lean: f.exit_lean_1500, }, engagement: EngagementObservation { - velocity_1184: float(f.entry_velocity_1184), kind_1248: f.family_1248, }, - jumper: JumperObservation { - geometry_kind_16: jumper.geometry.geometry_kind, - family_20: family(jumper.family)?, - energy_24: jumper.energy, - high_side_32: jumper.geometry.high_side, - normal_48: jumper.geometry.normal, - direction_64: jumper.geometry.direction, - upmost_normal_80: jumper.geometry.upmost, - point_96: jumper.geometry.point, - }, }) } diff --git a/crates/skate-game/src/physics/player_input/grind/tests.rs b/crates/skate-game/src/physics/player_input/grind/tests.rs index 4592d04b..b8660303 100644 --- a/crates/skate-game/src/physics/player_input/grind/tests.rs +++ b/crates/skate-game/src/physics/player_input/grind/tests.rs @@ -197,7 +197,7 @@ fn empty_authored_world_completes_active_grind_without_error_stub() { assert_eq!(p.grind.normal_1152, [0; 4]); assert_eq!(host.events, ["grind"]); assert!(result.wipeout_reasons.is_empty()); - assert_eq!(result.observation.jumper.family_20 as u32, 3); + assert_eq!(s.jumper.family as u32, 3); } #[test] diff --git a/crates/skate-game/src/physics/player_state/post_input.rs b/crates/skate-game/src/physics/player_state/post_input.rs index 00d9ffa4..6f3b22e2 100644 --- a/crates/skate-game/src/physics/player_state/post_input.rs +++ b/crates/skate-game/src/physics/player_state/post_input.rs @@ -32,62 +32,6 @@ impl PostInputState { } } } -///Scorer/decision state that still advances in the genuine no-candidate path -///82D8AB08 ->82D86808/82D86C88/82D86DE8/82D89F58/82D739D8. -pub(crate) struct EmptyEdgePost { - free_frames: u32, - duration: u32, - engage_state: u32, - expiry: f32, - pop_frames: u32, - lean: [f32; 2], - reset_frames: u32, - previous_grind: u32, - blend: f32, -} -impl EmptyEdgePost { - fn new() -> Self { - Self { - free_frames: 0, - duration: 0, - engage_state: 0, - expiry: 0.0, - pop_frames: 0, - lean: [0.0; 2], - reset_frames: 0, - previous_grind: 0, - blend: 0.0, - } - } - fn advance(&mut self, p: &mut skate_core::player::input_phase::ProcessedPhysicsInput) { - //No candidate means82D8ACF0 skips wallassist,82D86318 skips geometry. - self.expiry = (self.expiry - p.timestep_2604).max(0.0); - self.free_frames = self.free_frames.wrapping_add(1); - if self.free_frames > 20 { - self.duration = 0; - self.engage_state = 2; - } - self.pop_frames = decrement(self.pop_frames); - for value in &mut self.lean { - *value *= f32::from_bits(0x3f68f5c3); - if value.abs() < 0.0001 { - *value = 0.0; - } - } - self.reset_frames = decrement(self.reset_frames); - if self.reset_frames > 0 { - p.flags_2476 |= 0x02000000; - } - if p.grind_words_2532_2536[0] != u32::MAX { - self.previous_grind = p.grind_words_2532_2536[0]; - } - self.blend = (self.blend + 0.0035).min(1.0); - } -} -fn decrement(v: u32) -> u32 { - let n = v.wrapping_sub(1); - if n & 0x80000000 != 0 { 0 } else { n } -} struct Services<'a> { heading: f32, grind: &'a mut super::super::player_input::grind::GrindInputState, diff --git a/crates/skate-game/src/physics/player_state/pre_state.rs b/crates/skate-game/src/physics/player_state/pre_state.rs index 4056b636..7d57c4b0 100644 --- a/crates/skate-game/src/physics/player_state/pre_state.rs +++ b/crates/skate-game/src/physics/player_state/pre_state.rs @@ -14,6 +14,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> skater.player_state.current(), PhysicalStateId::PhysicsGround | PhysicalStateId::PhysicsAir + | PhysicalStateId::PhysicsAirSecondary | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround | PhysicalStateId::KnownAir | PhysicalStateId::BipedAir diff --git a/crates/skate-game/src/physics/player_state/publication.rs b/crates/skate-game/src/physics/player_state/publication.rs index a4a44a8a..d1edefaa 100644 --- a/crates/skate-game/src/physics/player_state/publication.rs +++ b/crates/skate-game/src/physics/player_state/publication.rs @@ -23,6 +23,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> state, PhysicalStateId::PhysicsGround | PhysicalStateId::PhysicsAir + | PhysicalStateId::PhysicsAirSecondary | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround | PhysicalStateId::KnownAir | PhysicalStateId::BipedAir diff --git a/crates/skate-game/src/physics/player_state/registry.rs b/crates/skate-game/src/physics/player_state/registry.rs index fcbfdc6c..454908a6 100644 --- a/crates/skate-game/src/physics/player_state/registry.rs +++ b/crates/skate-game/src/physics/player_state/registry.rs @@ -31,6 +31,7 @@ impl StateRegistry { PhysicalStateId::Sleeping | PhysicalStateId::PhysicsGround | PhysicalStateId::PhysicsAir + | PhysicalStateId::PhysicsAirSecondary | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround | PhysicalStateId::KnownAir | PhysicalStateId::BipedAir @@ -72,6 +73,7 @@ impl StateRegistry { | ( PhysicalStateId::PhysicsGround | PhysicalStateId::PhysicsAir + | PhysicalStateId::PhysicsAirSecondary | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround | PhysicalStateId::KnownAir | PhysicalStateId::BipedAir @@ -84,6 +86,7 @@ impl StateRegistry { | PhysicalStateId::LandingOnDeck, PhysicalStateId::PhysicsGround | PhysicalStateId::PhysicsAir + | PhysicalStateId::PhysicsAirSecondary | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround | PhysicalStateId::KnownAir | PhysicalStateId::BipedAir @@ -103,6 +106,19 @@ impl StateRegistry { mod tests { use super::*; + #[test] + fn grind_trick_darkslide_exit_and_return_have_connected_lifecycles() { + use PhysicalStateId::*; + let registry = StateRegistry::new(); + assert!(registry.can_transition(GrindDarkslide, PhysicsAirSecondary)); + assert!(registry.can_transition(PhysicsAirSecondary, PhysicsAir)); + assert!(registry.can_transition(PhysicsAirSecondary, WipeoutGround)); + assert!(registry.can_transition(PhysicsAirSecondary, Teleporting)); + assert!(!registry.can_transition(Sleeping, PhysicsAirSecondary)); + let state = registry.capability(PhysicsAirSecondary); + assert!(state.has_enter && state.has_exit); + } + #[test] fn registry_exposes_connected_biped_state_lifecycles() { let registry = StateRegistry::new(); diff --git a/crates/skate-game/src/physics/player_state/transition.rs b/crates/skate-game/src/physics/player_state/transition.rs index f908ad50..8821a7c6 100644 --- a/crates/skate-game/src/physics/player_state/transition.rs +++ b/crates/skate-game/src/physics/player_state/transition.rs @@ -19,6 +19,7 @@ impl PhysicalStateCalls for Calls { PhysicalStateId::Sleeping | PhysicalStateId::PhysicsGround | PhysicalStateId::PhysicsAir + | PhysicalStateId::PhysicsAirSecondary | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround | PhysicalStateId::KnownAir | PhysicalStateId::BipedAir @@ -40,6 +41,7 @@ impl PhysicalStateCalls for Calls { call.state.state, PhysicalStateId::PhysicsGround | PhysicalStateId::PhysicsAir + | PhysicalStateId::PhysicsAirSecondary | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround | PhysicalStateId::KnownAir | PhysicalStateId::BipedAir @@ -161,6 +163,7 @@ pub(super) fn set( PhysicalStateId::FootPlant => skater.footplant.reset(), //Exit82D4C5A8 PhysicalStateId::Boneless => {}, //empty82D4C9B4 PhysicalStateId::PhysicsGround => super::super::ground_exit::exit(physics, skater), + PhysicalStateId::PhysicsAirSecondary => super::super::grind_trick::exit(physics), PhysicalStateId::PhysicsAir => super::super::air_phase::exit(skater), PhysicalStateId::KnownAir => { super::super::known_air::exit(physics, skater, requested as u32)? @@ -184,6 +187,7 @@ pub(super) fn set( PhysicalStateId::FootPlant => super::super::footplant::ground::enter(physics, skater), PhysicalStateId::Boneless => super::super::boneless::enter(physics, skater), PhysicalStateId::PhysicsGround => super::super::ground_phase::enter(physics, skater), + PhysicalStateId::PhysicsAirSecondary => super::super::grind_trick::enter(physics, skater), PhysicalStateId::PhysicsAir => super::super::air_phase::enter(physics, skater), PhysicalStateId::KnownAir => super::super::known_air::enter(physics, skater), PhysicalStateId::BipedAir => super::super::biped_air::enter(physics, skater), @@ -208,3 +212,43 @@ pub(super) fn set( physics.exchange.request_state(requested)?; Ok(()) } + +#[cfg(test)] +mod grind_trick_tests { + use super::*; + #[test] + #[ignore = "requires converted stock assets; headless lifecycle regression"] + fn grind_trick_darkslide_exit_runs_native_adapters() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = crate::physics::PlayerControls::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let input = crate::input::ControllerInput::default(); + for _ in 0..5 { + crate::physics::frame::advance(&mut physics, &mut skater, &mut controls, &graphs, + &mut input.player_actions(), false, &mut camera).unwrap(); + } + set(&mut physics, &mut skater, PhysicalStateId::GrindDarkslide).unwrap(); + assert_eq!(skater.player_state.current(), PhysicalStateId::GrindDarkslide); + set(&mut physics, &mut skater, PhysicalStateId::PhysicsAirSecondary).unwrap(); + assert_eq!(skater.player_state.current(), PhysicalStateId::PhysicsAirSecondary); + assert_eq!(skater.player_input.processed.state_2504, 405); + assert_eq!(skater.player_input.processed.state_2508, 202); + for _ in 0..4 { + crate::physics::player_state::pre_state(&mut physics, &mut skater).unwrap(); + crate::physics::grind_trick::advance(&mut physics, &mut skater).unwrap(); + crate::physics::wipeout::check_after_physics(&physics, &mut skater).unwrap(); + crate::physics::grind_trick::post_velocity(&mut physics, &skater); + crate::physics::player_state::publish(&mut physics, &mut skater).unwrap(); + } + set(&mut physics, &mut skater, PhysicalStateId::PhysicsAir).unwrap(); + assert_eq!(skater.player_input.processed.state_2504, 202); + assert_eq!(skater.player_state.current(), PhysicalStateId::PhysicsAir); + assert_eq!(physics.settings.wheel_material, physics.settings.standard_wheel_material); + assert!(physics.board.bodies().iter().all(|b| b.inertia.linear_drag == 0.0)); + } +} diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 984c4ff2..8e1cd9a4 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -44,6 +44,7 @@ pub(crate) struct SkaterRuntime { pub biped_air: super::biped_air::BipedAir, pub landing_on_deck: super::landing_on_deck::Runtime, pub landing_deck: super::offboard::landing_deck::Owner, + pub grind_trick: super::grind_trick::GrindTrick, pub ground_animation: super::ground_animation::GroundAnimationRuntime, pub ground_animation_settings: super::ground_animation::GroundAnimationSettings, pub revert_state: super::revert_state::RevertState, @@ -56,10 +57,12 @@ pub(crate) struct SkaterRuntime { pub handplant: super::handplant::Handplant, pub wipeout: super::wipeout::Wipeout, pub wipeout_state: super::wipeout_states::WipeoutState, - pub respawn: super::respawn::Runtime, + pub(super) respawn: super::respawn::Runtime, pub teleport_state: super::teleport_state::Runtime, pub skeleton: SkeletonBody, pub skeleton_joints: SkeletonJoints, + pub(crate) mod_part_overrides: std::collections::BTreeMap, + pub(crate) mod_contact_frame: crate::modding::player_physics::ContactFrame, pub(crate) mod_joint_overrides: std::collections::BTreeMap, pub skeleton_drives: SkeletonDrives, pub skeleton_collision: SkeletonCollisionMode, @@ -255,6 +258,7 @@ impl SkaterRuntime { biped_air: super::biped_air::BipedAir::load(&data)?, landing_on_deck: super::landing_on_deck::Runtime::load(&data)?, landing_deck: super::offboard::landing_deck::Owner::load(&data)?, + grind_trick: Default::default(), ground_animation: Default::default(), ground_animation_settings: super::ground_animation::GroundAnimationSettings::load(&data)?, revert_state: super::revert_state::RevertState::load(&data)?, @@ -268,6 +272,7 @@ impl SkaterRuntime { wipeout: super::wipeout::Wipeout::load(&data)?, wipeout_state, teleport_state: super::teleport_state::Runtime::new( + #[cfg(test)] super::teleport_state::Checkpoint { transform: spawn, on_board: true, @@ -276,6 +281,8 @@ impl SkaterRuntime { skeleton, skeleton_joints, mod_joint_overrides: Default::default(), + mod_contact_frame: Default::default(), + mod_part_overrides: Default::default(), skeleton_drives, collision_feedback: skeleton_body::load_feedback(&data, skeleton_collision.settings)?, skeleton_collision, diff --git a/crates/skate-game/src/physics/skeleton_colliders.rs b/crates/skate-game/src/physics/skeleton_colliders.rs index 71400f1e..fa53a7e5 100644 --- a/crates/skate-game/src/physics/skeleton_colliders.rs +++ b/crates/skate-game/src/physics/skeleton_colliders.rs @@ -37,6 +37,13 @@ pub(crate) fn retain_world_volumes( pub(crate) fn enabled_volumes( skeleton: &SkeletonBody, collision: &SkeletonCollisionMode, +) -> Result, String> { + volumes_with_parts(skeleton, &collision.parts) +} + +pub(crate) fn volumes_with_parts( + skeleton: &SkeletonBody, + parts: &[skate_core::physics::skeleton_body::SkeletonPartCollision; 26], ) -> Result, String> { let transforms = skeleton.part_transforms(); let mut volumes = Vec::new(); @@ -44,7 +51,7 @@ pub(crate) fn enabled_volumes( .definition .parts .iter() - .zip(&collision.parts) + .zip(parts) .enumerate() { if !state.enabled { diff --git a/crates/skate-game/src/physics/skeleton_feedback.rs b/crates/skate-game/src/physics/skeleton_feedback.rs index 12150c7b..d6066cdb 100644 --- a/crates/skate-game/src/physics/skeleton_feedback.rs +++ b/crates/skate-game/src/physics/skeleton_feedback.rs @@ -167,12 +167,12 @@ fn collect(physics: &GamePhysics, skater: &SkaterRuntime) -> Vec Vec bool { +fn is_mod_solid_contact(id: u32, physics: &GamePhysics) -> bool { matches!( CollisionBody::from_contact_id(id), CollisionBody::Attached(index) diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs index 057d2ad9..0650c740 100644 --- a/crates/skate-game/src/physics/solve.rs +++ b/crates/skate-game/src/physics/solve.rs @@ -10,11 +10,18 @@ use skate_core::physics::{ skeleton_body::PART_COUNT, }; -pub(super) fn advance( +pub(super) fn advance(physics:&mut GamePhysics, skater:&mut SkaterRuntime,truck_targets:[f32;2])->Result<(),String> { + let restore=crate::modding::player_physics::apply_parts(skater); + let result=advance_inner(physics,skater,truck_targets); + restore.restore(skater); + result +} +fn advance_inner( physics: &mut GamePhysics, skater: &mut SkaterRuntime, truck_targets: [f32; 2], ) -> Result<(), String> { + let mod_before = crate::modding::player_physics::before_solve(physics,skater); let before = diagnostics::snapshot(physics, skater); diagnostics::validate(&before, "before shared solve").map_err(|error| format!( "{error}; com_frame={:?}; lifted_com_frame={:?}; animation_root={:?}; biped_position={:?}; biped_surface={:?}", @@ -73,6 +80,7 @@ pub(super) fn advance( let dt = physics.settings.step.simulation.time_step; let mut joints = crate::modding::player_physics::joints(skater) .build(skater.skeleton.bodies(), ATTACHED_REACTION_BASE, dt); + crate::modding::player_physics::filter_joints(skater, &mut joints); let mut drives = skater.skeleton_drives.build( skater.skeleton.bodies(), ATTACHED_REACTION_BASE, @@ -90,6 +98,7 @@ pub(super) fn advance( dt, &mut drives.rows, ); + crate::modding::player_physics::filter_possession(skater, &mut drives.rows, skeleton_drive_count); let bodies = skater .skeleton .bodies_mut() @@ -108,6 +117,8 @@ pub(super) fn advance( drives: &mut drives.rows, }, ); + skater.mod_contact_frame = crate::modding::player_physics::after_solve(physics,skater,&mod_before); + skater.mod_contact_frame.joint_loads=crate::modding::player_physics::joint_loads(skater,&joints,dt); physics.network_proxies.capture_dynamics_reactions(physics.board.solved_reactions(), dt); if let Err(error) = diagnostics::validate( &diagnostics::snapshot(physics, skater), "after shared solve", diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs index 72da143f..1828e2c0 100644 --- a/crates/skate-game/src/physics/teleport_state.rs +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -20,25 +20,23 @@ pub(crate) struct Checkpoint { pub(crate) struct Runtime { state: TeleportState, + #[cfg(test)] checkpoint: Checkpoint, pending_reply: Option, manual_on_board: Option, } impl Runtime { /// Explicit manual return uses the actor-reset publication without replacing - /// the automatic recovery checkpoint owned by this runtime. + /// the automatic recovery checkpoint owned by the respawn system. pub fn request_manual(&mut self, transform: AnimationPartTransform, on_board: bool) { 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 take_manual_on_board(&mut self) -> Option { self.manual_on_board.take() } - pub(super) fn set_checkpoint(&mut self, checkpoint: Checkpoint) { - self.checkpoint = checkpoint; - self.pending_reply = None; - } - pub fn new(checkpoint: Checkpoint) -> Self { + pub fn new(#[cfg(test)] checkpoint: Checkpoint) -> Self { Self { state: TeleportState::default(), + #[cfg(test)] checkpoint, pending_reply: None, manual_on_board: None, @@ -56,6 +54,7 @@ impl Runtime { == Update::RequestCheckpoint } /// Host response for an independently verified actor-reset request. + #[cfg(test)] pub fn request_checkpoint(&mut self) { self.reply(self.checkpoint); } diff --git a/crates/skate-game/src/physics/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs index 83c0c16b..73222b1b 100644 --- a/crates/skate-game/src/physics/wipeout.rs +++ b/crates/skate-game/src/physics/wipeout.rs @@ -129,6 +129,7 @@ pub(super) fn check_after_physics( PhysicalStateId::GroundAnimation => { skater.wipeout.check_ground_animation(&observations, 1.0) } + PhysicalStateId::PhysicsAirSecondary => skater.wipeout.check_ground_animation(&observations, skater.grind_trick.wipeout_scale()), PhysicalStateId::PhysicsAir => skater.wipeout.check_air(&observations, false), _ => Ok(()), //Other concrete states dispatch their own postphysics check. } diff --git a/crates/skate-game/src/profiling.rs b/crates/skate-game/src/profiling.rs index 3549726e..4e375d70 100644 --- a/crates/skate-game/src/profiling.rs +++ b/crates/skate-game/src/profiling.rs @@ -85,34 +85,33 @@ struct Capture { } impl Capture { - fn write(&self, phase: &str, name: &str, thread: u64) { - if !self.recording.load(Ordering::Relaxed) { - return; - } - let micros = self.started.elapsed().as_micros(); - let mut sink = self.events.lock().unwrap_or_else(|e| e.into_inner()); - let Some(file) = sink.as_mut() else { return }; - let first = self.written.fetch_add(1, Ordering::Relaxed) == 0; - let separator = if first { "" } else { ",\n" }; - // Escaping: span names are compile-time literals in this codebase, so a - // quote would be a programming error rather than untrusted input. - let _ = write!( - file, - "{separator}{{\"ph\":\"{phase}\",\"name\":\"{name}\",\"cat\":\"skate\",\ - \"pid\":1,\"tid\":{thread},\"ts\":{micros}}}" - ); + fn write(&self, name:&str, thread:u64, begin:Instant, end:Instant) { + if !self.recording.load(Ordering::Relaxed) {return;} + let event=serde_json::json!({"ph":"X","name":name,"cat":"skate","pid":1,"tid":thread, + "ts":begin.saturating_duration_since(self.started).as_micros() as u64, + "dur":end.saturating_duration_since(begin).as_micros() as u64}); + let mut sink=self.events.lock().unwrap_or_else(|e|e.into_inner()); + let Some(file)=sink.as_mut() else{return;}; + if self.written.fetch_add(1,Ordering::Relaxed)!=0 {let _=file.write_all(b",\n");} + let _=serde_json::to_writer(file,&event); } - fn metadata(&self, name: &str, value: &str) { + fn metadata(&self, name:&str, value:&str) { + let mut sink=self.events.lock().unwrap_or_else(|e|e.into_inner()); + let Some(file)=sink.as_mut() else{return;}; + if self.written.fetch_add(1,Ordering::Relaxed)!=0 {let _=file.write_all(b",\n");} + let _=serde_json::to_writer(file,&serde_json::json!({"ph":"M","name":name,"cat":"skate","pid":1,"tid":0,"ts":0,"args":{"value":value}})); + } + + fn frame_interval(&self, milliseconds: f64) { let mut sink = self.events.lock().unwrap_or_else(|e| e.into_inner()); - let Some(file) = sink.as_mut() else { return }; - let first = self.written.fetch_add(1, Ordering::Relaxed) == 0; - let separator = if first { "" } else { ",\n" }; - let _ = write!( - file, - "{separator}{{\"ph\":\"M\",\"name\":\"{name}\",\"cat\":\"skate\",\ - \"pid\":1,\"tid\":0,\"ts\":0,\"args\":{{\"value\":\"{value}\"}}}}" - ); + let Some(file) = sink.as_mut() else { return; }; + if self.written.fetch_add(1, Ordering::Relaxed) != 0 { let _ = file.write_all(b",\n"); } + let _ = serde_json::to_writer(file, &serde_json::json!({ + "ph":"C", "name":"frame_interval_ms", "cat":"skate", "pid":1, + "tid":THREAD_ID.with(|id|*id), "ts":self.started.elapsed().as_micros() as u64, + "args":{"value":milliseconds} + })); } fn finish(&self) { @@ -135,6 +134,14 @@ thread_local! { } static NEXT_THREAD: AtomicU64 = AtomicU64::new(1); +thread_local! { static ENTERED:std::cell::RefCell>=const {std::cell::RefCell::new(Vec::new())}; } +struct TraceLabel(String); +impl bevy::log::tracing::field::Visit for TraceLabel { + fn record_debug(&mut self,field:&bevy::log::tracing::field::Field,value:&dyn std::fmt::Debug) { + use std::fmt::Write;let _=write!(&mut self.0," {}={value:?}",field.name()); + } +} + struct ChromeLayer { capture: &'static Capture, } @@ -143,21 +150,24 @@ impl Layer for ChromeLayer where S: Subscriber + for<'a> LookupSpan<'a>, { - fn on_new_span(&self, _attrs: &Attributes<'_>, _id: &Id, _ctx: Context<'_, S>) {} - - fn on_enter(&self, id: &Id, ctx: Context<'_, S>) { - if let Some(span) = ctx.span(id) { - let thread = THREAD_ID.with(|id| *id); - self.capture.write("B", span.name(), thread); + fn on_new_span(&self, attrs:&Attributes<'_>, id:&Id, ctx:Context<'_,S>) { + let mut label=TraceLabel(attrs.metadata().name().to_owned());attrs.record(&mut label); + if let Some(span)=ctx.span(id) {span.extensions_mut().insert(label);} + } + fn on_enter(&self, id:&Id, _ctx:Context<'_,S>) { + if self.capture.recording.load(Ordering::Relaxed) { + ENTERED.with(|stack|stack.borrow_mut().push((id.clone(),Instant::now()))); + } + } + fn on_exit(&self, id:&Id, ctx:Context<'_,S>) { + let begin=ENTERED.with(|stack| {let mut stack=stack.borrow_mut();let at=stack.iter().rposition(|(entered,_)|entered==id)?;Some(stack.remove(at).1)}); + if let (Some(begin),Some(span))=(begin,ctx.span(id)) { + let end=Instant::now();let extensions=span.extensions(); + let label=extensions.get::().map_or(span.name(),|n|n.0.as_str()); + self.capture.write(label,THREAD_ID.with(|id|*id),begin,end); } } - fn on_exit(&self, id: &Id, ctx: Context<'_, S>) { - if let Some(span) = ctx.span(id) { - let thread = THREAD_ID.with(|id| *id); - self.capture.write("E", span.name(), thread); - } - } } /// Held by `main` for the process lifetime; closes the JSON array on drop so a @@ -270,7 +280,7 @@ pub(crate) fn install(app: &mut App) { fn controls(keys: Res>, time: Res>, mut started: Local>) { let Some(capture) = CAPTURE.get() else { return }; - if keys.just_pressed(KeyCode::F9) && !capture.recording.load(Ordering::Relaxed) { + if keys.just_pressed(KeyCode::F9) && !capture.closed.load(Ordering::Relaxed) && !capture.recording.load(Ordering::Relaxed) { capture.recording.store(true, Ordering::SeqCst); *started = Some(time.elapsed_secs()); info!("SKATE_TRACE capture started"); @@ -280,6 +290,7 @@ fn controls(keys: Res>, time: Res>, mut started: info!("SKATE_TRACE capture stopped"); } if capture.recording.load(Ordering::Relaxed) { + capture.frame_interval(time.delta_secs_f64() * 1000.0); let begin = started.get_or_insert(time.elapsed_secs()); if let Some(limit) = STOP_AFTER.get().copied().flatten() { if time.elapsed_secs() - *begin >= limit { @@ -289,3 +300,42 @@ fn controls(keys: Res>, time: Res>, mut started: } } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn trace_has_complete_spans_dynamic_labels_and_escaped_metadata() { + let path = std::env::temp_dir().join(format!("skate-trace-{}-{}.json", std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + let mut file = BufWriter::new(File::create(&path).unwrap()); + file.write_all(b"[\n").unwrap(); + let capture: &'static Capture = Box::leak(Box::new(Capture { + started: Instant::now(), recording: AtomicBool::new(false), closed: AtomicBool::new(false), + events: Mutex::new(Some(file)), written: AtomicU64::new(0), + })); + let subscriber = bevy::log::tracing_subscriber::registry().with(ChromeLayer { capture }); + let metadata = "map \"quoted\" \\ assets\nnext"; + capture.metadata("map", metadata); + bevy::log::tracing::subscriber::with_default(subscriber, || { + { let _ignored = bevy::log::tracing::info_span!("before_capture").entered(); } + capture.recording.store(true, Ordering::SeqCst); + let outer = bevy::log::tracing::info_span!("mods.callback", mod_id = "broken-bones", callback = "on_fixed_update"); + let _outer = outer.enter(); + { let _inner = bevy::log::tracing::info_span!("nested").entered(); } + }); + capture.finish(); + capture.finish(); + let events: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + std::fs::remove_file(&path).unwrap(); + let rows = events.as_array().unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0]["args"]["value"], metadata); + assert_eq!(rows[1]["name"], "nested"); + assert!(rows[2]["name"].as_str().unwrap().contains("broken-bones")); + assert!(rows[2]["name"].as_str().unwrap().contains("on_fixed_update")); + assert_eq!(rows[2]["ph"], "X"); + assert!(rows[2]["ts"].as_u64().unwrap() <= rows[1]["ts"].as_u64().unwrap()); + assert!(rows[2]["dur"].as_u64().unwrap() >= rows[1]["dur"].as_u64().unwrap()); + } +} diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 12fc7ca3..b40c6d81 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -102,7 +102,7 @@ impl Material for CharacterMaterial { #[derive(Component)] struct ShadowSource; -/// Mod graphics and other dynamic props that should sample map irradiance. +/// Package-owned graphics retain their authored PBR materials. #[derive(Component)] pub(crate) struct ModGraphicsLit; @@ -210,12 +210,13 @@ fn load(mut commands: Commands, config: Res, sources: Que }); } fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { - // World + skater casters, sampled only by the character shader. + // Scene sun for authored PBR materials, plus all-caster shadow visibility + // sampled by the native character shader. commands.spawn(( ShadowSource, - Name::new("Character shadow visibility"), + Name::new("Scene sun and shadow visibility"), DirectionalLight { - illuminance: 0., + illuminance: 11_000., shadows_enabled: true, affects_lightmapped_mesh_diffuse: false, ..default() @@ -228,11 +229,11 @@ fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { } .build(), )); - // Only skinned player pieces inhabit layer 28. The world receiver samples + // Characters and mod graphics inhabit layer 28. The world receiver samples // this separate map so baked building/terrain shadows are not re-applied. commands.spawn(( ShadowSource, - Name::new("Player shadow onto baked world"), + Name::new("Dynamic object shadows onto baked world"), DirectionalLight { illuminance: 0., shadows_enabled: true, @@ -253,12 +254,6 @@ fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { )); } -fn template_material(data: &LightingData) -> Option<&MaterialData> { - data.materials - .get("character.default_cloth") - .or_else(|| data.materials.values().next()) -} - fn bind( mut commands: Commands, lighting: Option>, @@ -269,7 +264,6 @@ fn bind( parents: Query<&ChildOf>, players: Query<(), Or<(With, With)>>, mod_graphics: Query<(), With>, - opacity: Query<&crate::modding::ModGraphicsOpacity>, parts: Query<(), With>, native: Query<&crate::custom_models::NativeModelRoot>, imports: Query<(), With>, @@ -277,11 +271,10 @@ fn bind( let Some(lighting) = lighting else { return; }; - let mod_template = template_material(&lighting.data); for (entity, name, handle, layers) in &entities { let under_player = parents.iter_ancestors(entity).any(|e| players.contains(e)); let under_mod = parents.iter_ancestors(entity).any(|e| mod_graphics.contains(e)); - if !under_player && !under_mod { + if under_mod || !under_player { continue; } // Modular CAC pieces own an extended material with tattoos/hair coverage. @@ -301,9 +294,7 @@ fn bind( } else { Some(&lighting.data.materials) }; - let data = table - .and_then(|m| m.get(&name.0)) - .or_else(|| under_mod.then(|| mod_template).flatten()); + let data = table.and_then(|m| m.get(&name.0)); let Some(data) = data else { continue; }; @@ -318,16 +309,15 @@ fn bind( } else { -1. }; - let opacity = parents.iter_ancestors(entity).find_map(|e| opacity.get(e).ok()).map_or(1., |o|o.0); - let mut tint=Vec4::from_array(m.base_color.to_linear().to_f32_array()); - tint.w *= opacity; + let tint=Vec4::from_array(m.base_color.to_linear().to_f32_array()); + let specular = data.specular.as_ref(); let material = materials.add(CharacterMaterial { params: CharacterParams { light: lighting.light, tint, options: Vec4::new( f32::from(m.normal_map_texture.is_some()), - f32::from(data.specular.is_some()), + f32::from(specular.is_some()), alpha_cutoff, f32::from(data.is_hair()), ), @@ -339,7 +329,7 @@ fn bind( }, diffuse: m.base_color_texture.clone(), normal: m.normal_map_texture.clone(), - mask: data.specular.as_ref().map(|path| { + mask: specular.map(|path| { server.load_with_settings( path.clone(), |settings: &mut bevy::image::ImageLoaderSettings| { @@ -347,7 +337,7 @@ fn bind( }, ) }), - alpha: if opacity < 0.999 {AlphaMode::Blend} else {m.alpha_mode}, + alpha: m.alpha_mode, }); // Skater meshes come and go with the outfit and the map, so binding one // that has since been despawned is routine rather than a fault. @@ -380,7 +370,6 @@ fn update( lighting: Option>, local_root: Query<(Entity, &Transform), With>, remote_roots: Query<(Entity, &Transform), With>, - mod_roots: Query<(Entity, &Transform), With>, character_meshes: Query<(Entity, &MeshMaterial3d)>, customiser_meshes: Query<(Entity, &MeshMaterial3d)>, parents: Query<&ChildOf>, @@ -429,19 +418,6 @@ fn update( &mut customiser, ); } - for (graphics, root) in &mod_roots { - let sh = lighting.probes.sample(root.translation, fallback); - apply_sh_to_hierarchy( - graphics, - sh, - lighting.light, - &character_meshes, - &customiser_meshes, - &parents, - &mut materials, - &mut customiser, - ); - } } fn customiser_retail_enabled(retail: &CharacterParams) -> bool { @@ -471,34 +447,125 @@ fn apply_sh_to_hierarchy( if !parents.iter_ancestors(entity).any(|ancestor| ancestor == root) { continue; } - if let Some(material) = materials.get_mut(&handle.0) { - if material.params.sh.iter().zip(&sh).any(|(a, b)| { - a.to_array().map(f32::to_bits) != b.to_array().map(f32::to_bits) - }) { - material.params.sh = sh; - } - if material.params.light != light { - material.params.light = light; - } + // Assets::get_mut queues Modified even when no field is written. Read + // first so stationary objects do not rebuild their GPU materials. + if materials.get(&handle.0).is_some_and(|material| lighting_changed(&material.params, &sh, light)) { + let material = materials.get_mut(&handle.0).unwrap(); + material.params.sh = sh; + material.params.light = light; } } for (entity, handle) in customiser_meshes { if !parents.iter_ancestors(entity).any(|ancestor| ancestor == root) { continue; } - if let Some(material) = customiser.get_mut(&handle.0) { - if !customiser_retail_enabled(&material.extension.retail) { - continue; - } + if customiser.get(&handle.0).is_some_and(|material| { + customiser_retail_enabled(&material.extension.retail) + && lighting_changed(&material.extension.retail, &sh, light) + }) { + let material = customiser.get_mut(&handle.0).unwrap(); material.extension.retail.light = light; material.extension.retail.sh = sh; } } } +fn lighting_changed(params: &CharacterParams, sh: &[Vec4; 9], light: Vec4) -> bool { + params.light.to_array().map(f32::to_bits) != light.to_array().map(f32::to_bits) + || params.sh.iter().zip(sh).any(|(a, b)| { + a.to_array().map(f32::to_bits) != b.to_array().map(f32::to_bits) + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn mod_materials_keep_package_textures_even_with_stock_material_names() { + use bevy::ecs::system::RunSystemOnce; + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::() + .init_asset::() + .init_asset::(); + let world = app.world_mut(); + let diffuse = world.resource_mut::>().add(Image::default()); + let normal = world.resource_mut::>().add(Image::default()); + let source = world.resource_mut::>().add(StandardMaterial { + base_color_texture: Some(diffuse.clone()), normal_map_texture: Some(normal.clone()), + base_color: Color::srgba(1., 0., 0., 0.5), alpha_mode: AlphaMode::Blend, ..default() + }); + world.insert_resource(Lighting { + data: LightingData { + materials: HashMap::from([ + ("Retail_Rostral".into(), MaterialData { shader: "character.face".into(), params: vec![[9.; 4]; 9], specular: Some("Rostral_specular.png".into()) }), + ("Retail_Organ".into(), MaterialData { shader: "character.default_cloth".into(), params: vec![[2.; 4]; 9], specular: Some("stock_cloth_specular.png".into()) }), + ]), default_sh: [[0.; 3]; 9], native: HashMap::new(), + }, probes: default(), light: Vec4::ONE, display_sh: None, + }); + let root = world.spawn(ModGraphicsLit).id(); + let meshes: Vec<_> = ["CarPaint", "Retail_Rostral"].into_iter().map(|name| { + world.spawn((ChildOf(root), GltfMaterialName(name.into()), MeshMaterial3d(source.clone()))).id() + }).collect(); + world.run_system_once(bind).unwrap(); + for mesh in meshes { + assert!(world.get::>(mesh).is_none()); + assert_eq!(world.get::>(mesh).unwrap().0, source); + } + } + + #[test] + fn unchanged_lighting_does_not_emit_material_modifications() { + use bevy::asset::AssetEvent; + use bevy::ecs::system::RunSystemOnce; + use crate::customiser_material::SkaterMaterial; + + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::() + .init_asset::(); + let character = app.world_mut().resource_mut::>().add(CharacterMaterial { + params: CharacterParams::default(), diffuse: None, normal: None, mask: None, + alpha: AlphaMode::Opaque, + }); + let mut skater = SkaterMaterial::default(); + skater.extension.retail.options.z = -1.; + let customiser = app.world_mut().resource_mut::>().add(skater); + let root = app.world_mut().spawn_empty().id(); + app.world_mut().spawn((ChildOf(root), MeshMaterial3d(character.clone()))); + app.world_mut().spawn((ChildOf(root), MeshMaterial3d(customiser.clone()))); + // Flush Added events before measuring updates. + app.update(); + app.world_mut().resource_mut::>>().clear(); + app.world_mut().resource_mut::>>().clear(); + + for (sh, light, expected) in [ + ([Vec4::ZERO; 9], Vec4::ZERO, 0), + ([Vec4::ONE; 9], Vec4::ONE, 1), + ([Vec4::ONE; 9], Vec4::ONE, 0), + ([Vec4::ONE; 9], Vec4::ZERO, 1), + ] { + app.world_mut().run_system_once(move | + meshes: Query<(Entity, &MeshMaterial3d)>, + cac: Query<(Entity, &MeshMaterial3d)>, + parents: Query<&ChildOf>, + mut materials: ResMut>, + mut customiser: ResMut>, + | apply_sh_to_hierarchy(root, sh, light, &meshes, &cac, &parents, &mut materials, &mut customiser)).unwrap(); + app.update(); + let character_events: Vec<_> = app.world_mut().resource_mut::>>().drain().collect(); + let customiser_events: Vec<_> = app.world_mut().resource_mut::>>().drain().collect(); + assert_eq!(character_events.iter().filter(|event| matches!(event, AssetEvent::Modified { .. })).count(), expected); + assert_eq!(customiser_events.iter().filter(|event| matches!(event, AssetEvent::Modified { .. })).count(), expected); + let params = &app.world().resource::>().get(&character).unwrap().params; + assert_eq!(params.sh, sh); + assert_eq!(params.light, light); + let params = &app.world().resource::>().get(&customiser).unwrap().extension.retail; + assert_eq!(params.sh, sh); + assert_eq!(params.light, light); + } + } + #[test] fn customiser_hair_families_include_pro_skater_defaults() { for shader in ["character.hair", "character.hair_ropa", "character.default_hair", "character.default_hair_ropa"] { @@ -576,36 +643,6 @@ mod tests { assert_eq!(world.resource::().shadow, Vec4::ZERO); } - #[test] - fn mod_graphics_template_prefers_default_cloth() { - let data = LightingData { - materials: HashMap::from([ - ( - "character.default_skin".into(), - MaterialData { - shader: "character.default_skin".into(), - params: vec![[0.; 4]; 9], - specular: None, - }, - ), - ( - "character.default_cloth".into(), - MaterialData { - shader: "character.default_cloth".into(), - params: vec![[1.; 4]; 9], - specular: None, - }, - ), - ]), - default_sh: [[0.; 3]; 9], - native: HashMap::new(), - }; - assert_eq!( - template_material(&data).unwrap().shader, - "character.default_cloth" - ); - } - #[test] fn world_receiver_source_excludes_world_casters_and_emits_no_light() { let mut world = World::new(); @@ -621,16 +658,17 @@ mod tests { .iter(&world) { let layers = layers.cloned().unwrap_or_default(); - assert_eq!(light.illuminance, 0.); assert!(light.shadows_enabled); assert!(layers.intersects(&player)); if light.affects_lightmapped_mesh_diffuse { + assert_eq!(light.illuminance, 0.); assert!(!layers.intersects(&terrain)); assert_eq!(cascades.bounds, vec![24.]); assert_eq!(light.shadow_normal_bias, 0.); assert!(light.shadow_depth_bias < 0.005); receivers += 1; } else { + assert_eq!(light.illuminance, 11_000.); assert!(layers.intersects(&terrain)); character_sources += 1; } diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index 88c04846..b4486493 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -183,9 +183,7 @@ impl FrameStateData { /// renders that unmapped signal straight into an 8-bit surface and clips /// everything bright to white. #[derive(Component, ExtractComponent, Clone, Copy, Default, Debug)] -pub(crate) struct RetailTone { - pub enabled: Vec4, -} +pub(crate) struct RetailTone; // --------------------------------------------------------------------------- // Render classes diff --git a/crates/skate-game/src/scoring_runtime.rs b/crates/skate-game/src/scoring_runtime.rs index 6f10fe22..2d5330d5 100644 --- a/crates/skate-game/src/scoring_runtime.rs +++ b/crates/skate-game/src/scoring_runtime.rs @@ -83,9 +83,15 @@ pub(crate) struct Runtime { sequence_active: bool, sequence_score: f32, trick_name: String, + display_base: String, + display_spin_degrees: i32, trick_seq: u32, pub(crate) landing_seq: u32, pub(crate) landed_trick: String, + pub(crate) landed_base: String, + pub(crate) landed_spin_degrees: i32, + pub(crate) landed_clean: bool, + pub(crate) landed_sketchy: bool, pub(crate) bail_seq: u32, settled_trick_seq: u32, was_bailing: bool, @@ -131,9 +137,12 @@ impl Runtime { sequence_active: false, sequence_score: 0., trick_name: String::new(), + display_base: String::new(), display_spin_degrees: 0, trick_seq: 0, landing_seq: 0, landed_trick: String::new(), + landed_base: String::new(), landed_spin_degrees: 0, + landed_clean: false, landed_sketchy: false, bail_seq: 0, settled_trick_seq: 0, was_bailing: false, @@ -643,6 +652,9 @@ impl Runtime { fakie, f.regular, ); + self.display_base = display::compose(&self.data, self.display_id, 0, 0, + switch, fakie, f.regular).0; + self.display_spin_degrees = self.spin_turns * 180; self.trick_name = name; self.stance = stance; } else if !self.sequence_active { @@ -745,6 +757,10 @@ impl Runtime { { self.landing_seq = self.landing_seq.saturating_add(1); self.landed_trick = self.trick_name.clone(); + self.landed_base = self.display_base.clone(); + self.landed_spin_degrees = self.display_spin_degrees; + self.landed_clean = self.clean; + self.landed_sketchy = self.sketchy; } self.settled_trick_seq = self.trick_seq; self.sequence_active = false; @@ -797,3 +813,10 @@ impl Runtime { #[cfg(test)] #[path = "scoring_runtime/tests.rs"] mod tests; + +impl Runtime { + pub(crate) fn mod_catalog(&self)->serde_json::Value { + serde_json::json!(self.data.definitions.iter().map(|d|serde_json::json!({"id":d.metadata.id,"identifier":d.identifier, + "label":d.label,"points":d.points,"type":d.trick_type,"class":d.metadata.class,"score_type":d.metadata.score_type,"variant":d.variant})).collect::>()) + } +} diff --git a/crates/skate-game/src/scoring_runtime/tests.rs b/crates/skate-game/src/scoring_runtime/tests.rs index 4a69ce5c..cbd39a1e 100644 --- a/crates/skate-game/src/scoring_runtime/tests.rs +++ b/crates/skate-game/src/scoring_runtime/tests.rs @@ -321,6 +321,8 @@ fn body_flips_with_grabs_and_spins_keep_names_rewards_and_landing() { } assert_eq!(r.landing_seq, 1); assert_eq!(r.landed_trick, displayed); + assert_eq!(r.landed_spin_degrees.abs(), degrees.abs() as i32); + assert!(!r.landed_base.is_empty()); assert!(r.sequence_score > 0.); } } diff --git a/crates/skate-game/src/session_marker/mod.rs b/crates/skate-game/src/session_marker/mod.rs index 4a6a8cdc..8a775fca 100644 --- a/crates/skate-game/src/session_marker/mod.rs +++ b/crates/skate-game/src/session_marker/mod.rs @@ -21,10 +21,6 @@ struct Marker { generation: u64, } -/// Audio owners consume these native GlobalFEPlaySound event IDs. -#[derive(Message, Clone, Copy, Debug)] -pub(crate) struct SessionMarkerAudio(pub u64); - #[derive(Resource, Default)] pub(crate) struct SessionMarker { marker: Option, @@ -53,7 +49,6 @@ impl Plugin for SessionMarkerPlugin { } } app.init_resource::() - .add_message::() .add_systems( PreUpdate, suspend.after(crate::map_transition::MapTransitionSet), @@ -104,7 +99,6 @@ fn update( mut skater: ResMut, validation: Res, replay: Res, - mut audio: MessageWriter, ) { if replay.active { return; @@ -162,9 +156,6 @@ fn update( foot_forward: skater.animation.foot_forward(), generation: map.generation, }); - audio.write(SessionMarkerAudio(0x0d6c_88a3_b91c_828f)); - } else { - audio.write(SessionMarkerAudio(0x66b3_afe3_b602_918c)); } } session.last_batch = input.consumed_batches; @@ -191,7 +182,6 @@ fn update( skater .teleport_state .request_manual(target.transform, target.on_board); - audio.write(SessionMarkerAudio(0x7f13_5f9f_d28f_7f21)); } Err(e) => { warn!("Session marker return rejected: {e}"); diff --git a/crates/skate-game/src/teleport_menu.rs b/crates/skate-game/src/teleport_menu.rs index 6f939133..fc7f2426 100644 --- a/crates/skate-game/src/teleport_menu.rs +++ b/crates/skate-game/src/teleport_menu.rs @@ -8,8 +8,6 @@ pub(crate) struct Destination { pub name: String, pub map: String, pub matrix: Option<[[f32; 4]; 4]>, - #[serde(default)] - pub unavailable_reason: Option, } #[derive(Deserialize)] struct Catalog { version: u32, destinations: Vec } diff --git a/crates/skate-mods/src/api.lua b/crates/skate-mods/src/api.lua index 10b650fb..9d91ea1f 100644 --- a/crates/skate-mods/src/api.lua +++ b/crates/skate-mods/src/api.lua @@ -24,6 +24,7 @@ sdk.ui = { version = 1 } function sdk.ui.menu(key, options) submit{kind="ui_menu",key=key,options=options} end function sdk.ui.remove_menu(key) submit{kind="ui_remove_menu",key=key} end function sdk.ui.text(key, text) submit{kind="overlay",key=key,text=text} end +function sdk.ui.multiplayer_debug(key, text) submit{kind="multiplayer_debug",key=key,text=text} end -- Persistent screen-space rectangles and text; update an existing key in place. function sdk.ui.canvas(key, options) options = options or {} @@ -286,6 +287,7 @@ end function sdk.audio.stop_all() submit{kind="audio_stop_all"} end sdk.player = {} +function sdk.player.suspend(suspended) submit{kind="player_suspend",suspended=suspended} end function sdk.player.physics() return as_table(sdk.snapshot.player_physics) or {} end function sdk.player.contacts() return sdk.player.physics().contacts or {} end function sdk.player.joints() return sdk.player.physics().joints or {} end @@ -381,8 +383,14 @@ function sdk.time.after(key, seconds, callback) timers[key]={at=sdk.time.elapsed+seconds,callback=callback} end function sdk.time.cancel(key) timers[key]=nil end +function sdk._timers_due(dt) + local at=sdk.time.elapsed+dt + for _,timer in pairs(timers) do if timer.at<=at then return true end end + return false +end function sdk._advance(dt) sdk.time.elapsed=sdk.time.elapsed+dt + if next(timers)==nil then return end 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) @@ -391,3 +399,60 @@ function sdk._advance(dt) if timer and timer.at<=sdk.time.elapsed then timers[key]=nil; timer.callback() end end end + +-- Unified low-level surface. All native IDs below are zero-based; Lua lists are not. +sdk.commands = {} +local command_serial, command_pending = 0, {} +function sdk.commands.request(key, command) + command_serial=command_serial+1;command_pending[key]=command_serial + submit{kind="request",key=key,token=command_serial,command=command} + return command_serial +end +function sdk.commands.result(key) + local result=((sdk.snapshot.command_results or {})[sdk.mod_id] or {})[key] + if result and result.token==command_pending[key] then return result end +end +sdk.engine = {version=1} +function sdk.engine.systems() return (sdk.snapshot.engine or {}).systems or {} end +function sdk.engine.inspect(key,system) sdk.commands.request(key,{kind="engine_inspect",system=system}) end +function sdk.engine.read(system) + local s=sdk.snapshot + if system=="input" then return {pad=s.pad,actions=s.actions,keys=s.keys} end + if system=="commands" then return (s.command_results or {})[sdk.mod_id] end + local key=({player="player",rig="player_physics",bodies="physics",world="map",camera="camera",network="network",scoring="player"})[system] + if key then return s[key] end + return (s.engine or {})[system] +end +sdk.rig = {} +function sdk.rig.read(fields) + if fields and sdk._rig_snapshot then return sdk._rig_snapshot(fields) end + return sdk.player.physics() +end +function sdk.rig.parts() return sdk.rig.read({"parts"}).parts or {} end +function sdk.rig.joints() return sdk.rig.read({"joints"}).joints or {} end +function sdk.rig.contacts() return sdk.rig.read({"contacts"}).contacts or {} end +function sdk.rig.configure_joint(index,options) sdk.player.set_joint(index,options) end +function sdk.rig.reset_joint(index) sdk.player.reset_joint(index) end +function sdk.rig.reset() sdk.player.reset_joints() end +sdk.bodies = {} +function sdk.bodies.read(ref) + if ref.kind=="mod" then return sdk.physics.read(ref.key) end + local rig=sdk.rig.read();local list=ref.kind=="skater" and rig.parts or ref.kind=="board" and rig.board + for _,b in ipairs(list or {}) do if b.index==ref.index then return b end end +end +function sdk.bodies.impulse(ref,value,point) + if ref.kind=="mod" then sdk.physics.impulse(ref.key,value,point) + else submit{kind="native_impulse",body={kind=ref.kind,index=ref.index},impulse=value,point=point,angular=false} end +end +function sdk.bodies.angular_impulse(ref,value) + if ref.kind=="mod" then sdk.physics.torque_impulse(ref.key,value) + else submit{kind="native_impulse",body={kind=ref.kind,index=ref.index},impulse=value,angular=true} end +end +function sdk.input.override_action(id,value) submit{kind="input_override",action=id,value=value} end + +sdk.graphs = {} +function sdk.graphs.read(graph) return (sdk.engine.read("graphs") or {})[graph] end +function sdk.graphs.set_enabled(graph,target,index,enabled) submit{kind="graph_gate",graph=graph,target=target,index=index,enabled=enabled} end + +function sdk.rig.configure_part(index,options) submit{kind="rig_part",index=index,options=options} end +function sdk.rig.reset_part(index) sdk.rig.configure_part(index,nil) end diff --git a/crates/skate-mods/src/archive.rs b/crates/skate-mods/src/archive.rs index 487cd945..9fbb7ccf 100644 --- a/crates/skate-mods/src/archive.rs +++ b/crates/skate-mods/src/archive.rs @@ -7,12 +7,20 @@ use std::path::{Path, PathBuf}; #[derive(Default)] pub struct Cache { entries: HashMap, + stamps: HashMap, session: Option, next: u64, } impl Cache { + pub fn invalidate(&mut self) {self.stamps.clear();} + pub fn materialize(&mut self, root: &Path, archive: &Path) -> Result { + let metadata=fs::metadata(archive).map_err(|e|e.to_string())?; + let stamp=(metadata.len(),metadata.modified().map_err(|e|e.to_string())?); + if self.stamps.get(archive)==Some(&stamp) { + if let Some((_,path))=self.entries.get(archive) {if path.is_dir(){return Ok(path.clone());}} + } let bytes = fs::read(archive).map_err(|e| e.to_string())?; let hash = { let mut h = 0u64; @@ -23,6 +31,7 @@ impl Cache { }; if let Some((prev, path)) = self.entries.get(archive) { if *prev == hash && path.is_dir() { + self.stamps.insert(archive.to_owned(),stamp); return Ok(path.clone()); } } @@ -90,6 +99,7 @@ impl Cache { } self.entries .insert(archive.to_owned(), (hash, destination.clone())); + self.stamps.insert(archive.to_owned(),stamp); Ok(destination) } } @@ -207,3 +217,63 @@ pub fn write_temp(path: &Path, bytes: &[u8]) -> Result<(), String> { let mut f = fs::File::create(path).map_err(|e| e.to_string())?; f.write_all(bytes).map_err(|e| e.to_string()) } + +/// Unchanged files are statted, not reread. Explicit rescans force content reads. +#[derive(Default)] +pub(crate) struct Fingerprints {entries:HashMap,u64)>} +impl Fingerprints { + pub fn get(&mut self,root:&Path,force:bool)->Result { + fn walk(dir:&Path,out:&mut Vec<(PathBuf,u64,std::time::SystemTime)>)->Result<(),String> { + for entry in fs::read_dir(dir).map_err(|e|e.to_string())? { + let entry=entry.map_err(|e|e.to_string())?; + if entry.file_name().to_string_lossy().starts_with('.') {continue;} + let m=entry.metadata().map_err(|e|e.to_string())?; + if m.is_dir(){walk(&entry.path(),out)?;}else{out.push((entry.path(),m.len(),m.modified().map_err(|e|e.to_string())?));} + } Ok(()) + } + let mut stamp=Vec::new();walk(root,&mut stamp)?;stamp.sort_by(|a,b|a.0.cmp(&b.0)); + if !force {if let Some((old,hash))=self.entries.get(root){if old==&stamp{return Ok(*hash);}}} + let hash=fingerprint(root)?;self.entries.insert(root.to_owned(),(stamp,hash));Ok(hash) + } +} + +#[cfg(test)] +mod fingerprint_tests { + use super::*; + #[test] + fn failed_changed_archive_is_not_cached_as_the_old_package() { + let root=std::env::temp_dir().join(format!("skate-zip-stamp-{}",std::process::id())); + fs::create_dir_all(&root).unwrap();let archive=root.join("test.zip"); + let mut zip=zip::ZipWriter::new(fs::File::create(&archive).unwrap()); + zip.start_file("main.lua",zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(b"return {}").unwrap();zip.finish().unwrap(); + let mut cache=Cache::default();let initial=cache.materialize(&root,&archive).unwrap(); + assert_eq!(cache.materialize(&root,&archive).unwrap(),initial); + fs::write(&archive,b"invalid changed zip").unwrap(); + assert!(cache.materialize(&root,&archive).is_err()); + assert!(cache.materialize(&root,&archive).is_err()); + drop(cache);fs::remove_file(&archive).unwrap();fs::remove_dir(root.join(".cache")).unwrap();fs::remove_dir(root).unwrap(); + } + + #[test] + fn metadata_cache_preserves_content_hash_and_detects_add_edit_remove_and_force() { + let root=std::env::temp_dir().join(format!("skate-fingerprint-{}",std::process::id()));fs::create_dir_all(&root).unwrap(); + fs::write(root.join("main.lua"),b"one").unwrap();let mut cache=Fingerprints::default(); + let one=cache.get(&root,false).unwrap();assert_eq!(one,fingerprint(&root).unwrap());assert_eq!(one,cache.get(&root,false).unwrap()); + fs::write(root.join("asset"),b"new").unwrap();let two=cache.get(&root,false).unwrap();assert_ne!(one,two); + fs::write(root.join("main.lua"),b"longer").unwrap();assert_ne!(two,cache.get(&root,false).unwrap()); + fs::remove_file(root.join("asset")).unwrap();let current=cache.get(&root,false).unwrap();assert_eq!(current,fingerprint(&root).unwrap()); + // Force must bypass even a matching metadata stamp. + cache.entries.get_mut(&root).unwrap().1=0;assert_eq!(current,cache.get(&root,true).unwrap()); + fs::remove_file(root.join("main.lua")).unwrap();fs::remove_dir(root).unwrap(); + } + #[test] + #[ignore="headless installed-package scan benchmark"] + fn compare_unchanged_skyline_scans() { + let root=PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../mods/Skyline_Drive_Mod"); + let mut cache=Fingerprints::default();let expected=cache.get(&root,false).unwrap();let begin=std::time::Instant::now(); + for _ in 0..20{assert_eq!(fingerprint(&root).unwrap(),expected);}let eager=begin.elapsed();let begin=std::time::Instant::now(); + for _ in 0..20{assert_eq!(cache.get(&root,false).unwrap(),expected);}let cached=begin.elapsed(); + eprintln!("SCAN_BENCH eager_ms={:.3} cached_ms={:.3}",eager.as_secs_f64()*50.,cached.as_secs_f64()*50.); + } +} diff --git a/crates/skate-mods/src/extensions.rs b/crates/skate-mods/src/extensions.rs index 06df32a7..a8085dc6 100644 --- a/crates/skate-mods/src/extensions.rs +++ b/crates/skate-mods/src/extensions.rs @@ -36,6 +36,9 @@ pub struct JointOverride { #[serde(default)] pub free_swing: Option, #[serde(default)] pub free_twist: Option, #[serde(default)] pub drive_enabled: Option, + #[serde(default)] pub enabled: Option, + #[serde(default)] pub descendants: bool, + #[serde(default)] pub possession_enabled: Option, } impl JointOverride { pub fn validate(&self) -> bool { @@ -61,3 +64,20 @@ mod tests { o.swing_limit=Some(-1.); assert!(!o.validate()); } } + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeBodyRef { pub kind: String, pub index: usize } +impl NativeBodyRef { + pub fn validate(&self) -> bool { match self.kind.as_str() { "skater"=>self.index<26,"board"=>self.index<7,_=>false } } +} + +#[derive(Clone,Debug,Default,Serialize,Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PartOverride { + pub motion:Option, pub collision:Option, pub friction:Option, + pub animation_drives:Option, pub possession_drives:Option, +} +impl PartOverride { + pub fn validate(&self)->bool {self.motion.as_deref().is_none_or(|m|matches!(m,"dynamic"|"frozen"|"static")) && self.friction.is_none_or(|f|f.is_finite() && (0.0..=10.0).contains(&f))} +} diff --git a/crates/skate-mods/src/lib.rs b/crates/skate-mods/src/lib.rs index cde57026..fda26202 100644 --- a/crates/skate-mods/src/lib.rs +++ b/crates/skate-mods/src/lib.rs @@ -64,11 +64,16 @@ pub struct Manager { pub commands: Vec<(String, Command)>, pub retired: Vec, last_scan: Instant, - pub snapshot: Value, + pub snapshot: std::sync::Arc, + /// Immutable sections shared across render frames until their native state changes. + pub snapshot_fields: SnapshotFields, invalid_since: BTreeMap, archives: archive::Cache, + fingerprints: archive::Fingerprints, } +pub type SnapshotFields = std::sync::Arc>>; + impl Manager { pub fn root(&self) -> &Path { &self.root @@ -83,9 +88,11 @@ impl Manager { commands: vec![], retired: vec![], last_scan: Instant::now() - Duration::from_secs(2), - snapshot: Value::Null, + snapshot: std::sync::Arc::new(Value::Null), + snapshot_fields: SnapshotFields::default(), invalid_since: BTreeMap::new(), archives: archive::Cache::default(), + fingerprints: archive::Fingerprints::default(), } } @@ -94,6 +101,7 @@ impl Manager { return; } self.last_scan = Instant::now(); + if force {self.archives.invalidate();} self.diagnostics.clear(); let mut found = BTreeMap::::new(); let mut duplicates = std::collections::BTreeSet::new(); @@ -140,7 +148,7 @@ impl Manager { let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; manifest.validate()?; - let hash = archive::fingerprint(&path)?; + let hash = self.fingerprints.get(&path,force)?; Ok::<_, String>((manifest, hash)) })(); match result { @@ -320,7 +328,16 @@ impl Manager { return; }; p.error = None; - match vm::Vm::new(&p.root, &p.manifest, &p.settings, &self.snapshot) { + // Top-level Lua initialization is a one-time operation and receives the + // same complete snapshot as callbacks, including shared native sections. + let mut initial = (*self.snapshot).clone(); + if !self.snapshot_fields.is_empty() { + if !initial.is_object() { initial = serde_json::json!({}); } + for (key, value) in self.snapshot_fields.iter() { + initial[key] = (**value).clone(); + } + } + match vm::Vm::new(&p.root, &p.manifest, &p.settings, &initial) { Ok(vm) => { p.vm = Some(vm); self.call_one(id, "on_load", Value::Null); @@ -336,7 +353,7 @@ impl Manager { 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) { + if let Err(e) = vm.call_shared("on_unload", Value::Null, &self.snapshot, None, &self.snapshot_fields) { p.error = Some(format!("on_unload: {e}")); } } @@ -359,7 +376,7 @@ impl Manager { .packages .get_mut(id) .and_then(|p| p.vm.as_mut()) - .map(|vm| vm.call(callback, payload, &self.snapshot)); + .map(|vm| vm.call_shared(callback, payload, &self.snapshot, None, &self.snapshot_fields)); match result { Some(Ok(cmds)) => self .commands @@ -373,6 +390,15 @@ impl Manager { self.call_one(id, callback, payload); } + pub fn call_with_physics(&mut self,id:&str,callback:&str,payload:Value,physics:Value) { + let result=self.packages.get_mut(id).and_then(|p|p.vm.as_mut()) + .map(|vm|vm.call_shared(callback,payload,&self.snapshot,Some(physics),&self.snapshot_fields)); + match result { + Some(Ok(commands))=>self.commands.extend(commands.into_iter().map(|c|(id.to_owned(),c))), + Some(Err(error))=>self.fail(id,format!("{callback}: {error}")),None=>{} + } + } + pub fn dispatch(&mut self, callback: &str, payload: Value) { let ids: Vec<_> = self .packages @@ -435,3 +461,15 @@ mod engine_api_tests; #[cfg(test)] #[path="tests/game_of_skate.rs"] mod game_of_skate_tests; + +#[cfg(test)] +#[path="tests/challenges.rs"] +mod challenge_tests; + +#[cfg(test)] +#[path="tests/general_api.rs"] +mod general_api_tests; + +#[cfg(test)] +#[path="tests/skyline_drive.rs"] +mod skyline_drive_tests; diff --git a/crates/skate-mods/src/tests/challenges.rs b/crates/skate-mods/src/tests/challenges.rs new file mode 100644 index 00000000..e96db6b4 --- /dev/null +++ b/crates/skate-mods/src/tests/challenges.rs @@ -0,0 +1,81 @@ +use crate::{vm::Vm, Command, validate_package}; +use serde_json::{json,Value}; +use std::{collections::BTreeMap,path::PathBuf}; +struct Harness { vm:Vm, snapshot:Value, wire:Value, text:String, commands:Vec } +impl Harness { + fn new(package:&str, settings:Value, id:&str, host:&str)->Self { + let root=PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples").join(package); + let manifest=validate_package(&root).unwrap(); + let mut options:BTreeMap=manifest.settings.iter().map(|(k,s)|(k.clone(),s.default.clone())).collect(); + for (k,v) in settings.as_object().unwrap(){options.insert(k.clone(),v.clone());} + let p=json!({"position":[0.,2.,0.],"heading":0.,"landing_seq":7,"bail_seq":0,"clean":true,"sketchy":false,"on_board":true,"bailing":false}); + let snapshot=json!({"player":p,"skaters":{"99":p,"1":p},"physics":{"bodies":{}},"network":{"active":true,"local_id":id,"host_id":host,"is_host":id==host,"players":["99","1"],"states":{}}}); + let vm=Vm::new(&root,&manifest,&options,&snapshot).unwrap(); + let mut h=Self{vm,snapshot,wire:json!({}),text:String::new(),commands:vec![]};h.call("on_load",json!({}));h + } + fn call(&mut self,name:&str,arg:Value) { + let commands=self.vm.call(name,arg,&self.snapshot).unwrap(); + for c in &commands {assert!(c.validate(),"{c:?}");match c { + Command::NetworkState{key,value}=>{assert!(serde_json::to_vec(value).unwrap().len()<=512);self.wire[key]=value.clone();} + Command::Overlay{text,..}=>self.text=text.clone(), _=>{} + }}self.commands.extend(commands); + } + fn event(&mut self,item:&str){self.call("on_event",json!({"name":"menu_action","menu":"challenge","item":item}));} + fn step(&mut self,dt:f64){self.call("on_update",json!({"dt":dt}));self.call("on_fixed_update",json!({"dt":dt}));} + fn land(&mut self,trick:&str){let p=&mut self.snapshot["skaters"]["1"];p["landed_trick"]=json!(trick);p["landing_seq"]=json!(p["landing_seq"].as_u64().unwrap()+1);} +} +#[test] +fn simon_confirmed_landing_requires_rideaway_and_bail_cancels_it() { + let mut h=Harness::new("simon-says",json!({"countdown_secs":1,"ride_away":1,"allow_flips":false,"allow_shuvits":false,"allow_grabs":false,"allow_grinds":false,"allow_manuals_plants":false,"allow_spins":false}),"99","99"); + h.event("start");h.step(1.1);assert_eq!(h.wire["match"]["p"],"copy"); + h.snapshot["network"]["states"]=json!({"simon.says":{"1":{"round":{"e":h.wire["match"]["e"],"n":1,"l":7,"b":0}}}}); + let trick=h.text.split("Call: ").nth(1).unwrap().split(" | ").next().unwrap().to_owned(); + h.snapshot["skaters"]["1"]["trick"]=json!(trick);h.step(0.1);assert_eq!(h.wire["match"]["p"],"copy"); + h.land(&trick);h.step(0.1);assert_eq!(h.wire["match"]["p"],"copy"); + h.snapshot["skaters"]["1"]["bail_seq"]=json!(1);h.step(1.1);assert_eq!(h.wire["match"]["p"],"copy"); + h.land(&trick);h.step(0.1);h.step(1.1); + assert_eq!(h.wire["match"]["p"],"break");assert_eq!(h.wire["match"]["w"],2);assert_eq!(h.wire["match"]["l"],"23"); +} +#[test] +fn simon_clients_follow_host_elimination_and_stop_restores_control() { + let mut h=Harness::new("simon-says",json!({}),"1","99");h.event("start");assert!(h.wire["match"].is_null()); + h.snapshot["network"]["states"]=json!({"simon.says":{"99":{"match":{"e":"one","p":"copy","n":1,"c":1,"ids":["99","1"],"l":"30","r":10,"w":0}}}}); + h.step(0.1);assert!(h.commands.iter().any(|c|matches!(c,Command::PlayerSuspend{suspended:true}))); + assert!(h.commands.iter().any(|c|matches!(c,Command::CameraWatch{peer:Some(p)} if p=="99"))); + h.commands.clear();h.snapshot["network"]["states"]["simon.says"]["99"]["match"]["p"]=json!("stopped");h.step(0.1); + assert!(h.commands.iter().any(|c|matches!(c,Command::PlayerSuspend{suspended:false}))); +} +#[test] +fn wipeout_uses_persistent_kinematics_and_sensor_hits_and_restores_origin() { + let mut h=Harness::new("wipeout-challenge",json!({}),"99","99");h.event("start"); + assert!(h.commands.iter().any(|c|matches!(c,Command::PlayerTeleport{options} if options.position==[-5.,43.6,-5.]))); + h.step(0.1); + assert!(h.commands.iter().any(|c|matches!(c,Command::PhysicsSpawn{key,body} if key=="wz01" && body.body_type==skate_dynamics::BodyType::Kinematic))); + h.snapshot["player"]["position"]=json!([0.,43.,0.]); + h.snapshot["physics"]["bodies"]["wz01"]=json!({"position":[4.5,43.1,0.],"player_overlapping":false}); + h.commands.clear();h.step(0.1); + assert!(h.commands.iter().any(|c|matches!(c,Command::PhysicsSetLinvel{key,..} if key=="wz01"))); + assert!(!h.commands.iter().any(|c|matches!(c,Command::PhysicsRemove{key} if key=="wz01"))); + h.step(2.1);h.snapshot["physics"]["bodies"]["wo.w1"]=json!({"player_overlapping":true});h.step(0.01); + assert!(h.text.contains("ELIMINATED")); + h.commands.clear();h.event("restart");assert!(h.commands.iter().any(|c|matches!(c,Command::PlayerTeleport{options} if options.position==[-5.,43.6,-5.]))); + h.commands.clear();h.event("stop");assert!(h.commands.iter().any(|c|matches!(c,Command::PlayerTeleport{options} if options.position==[0.,2.,0.]))); +} +#[test] +fn wipeout_counts_contact_entries_not_frames() { + let mut h=Harness::new("wipeout-challenge",json!({}),"99","99");h.event("start");h.snapshot["player"]["position"]=json!([0.,43.,0.]);h.step(2.1); + h.snapshot["physics"]["bodies"]["wz01"]=json!({"position":[4.5,43.1,0.],"player_overlapping":true});h.step(0.01); + assert!(h.text.contains("Next hazard"));h.step(1.2);assert!(!h.text.contains("ELIMINATED")); + h.snapshot["physics"]["bodies"]["wz01"]["player_overlapping"]=json!(false);h.step(0.01); + h.snapshot["physics"]["bodies"]["wz01"]["player_overlapping"]=json!(true);h.step(0.01);assert!(h.text.contains("ELIMINATED")); +} + +#[test] +fn simon_uses_settled_body_rotation_not_the_360_in_a_board_flip_name() { + let mut h=Harness::new("simon-says",json!({"countdown_secs":1,"ride_away":1,"allow_flips":false,"allow_shuvits":false,"allow_ollies":false,"allow_grabs":false,"allow_grinds":false,"allow_manuals_plants":false}),"99","99"); + h.event("start");h.step(1.1); + h.snapshot["network"]["states"]=json!({"simon.says":{"1":{"round":{"e":h.wire["match"]["e"],"n":1,"l":7,"b":0}}}}); + let spin:u32=h.text.split("Call: ").nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + h.snapshot["skaters"]["1"]["landed_spin_degrees"]=json!(0);h.land("360 Flip");h.step(0.1);h.step(1.1);assert_eq!(h.wire["match"]["p"],"copy"); + h.snapshot["skaters"]["1"]["landed_spin_degrees"]=json!(spin);h.land("Tailwalk Frontflip");h.step(0.1);h.step(1.1);assert_eq!(h.wire["match"]["w"],2); +} diff --git a/crates/skate-mods/src/tests/game_of_skate.rs b/crates/skate-mods/src/tests/game_of_skate.rs index 6f914a97..502ca5ed 100644 --- a/crates/skate-mods/src/tests/game_of_skate.rs +++ b/crates/skate-mods/src/tests/game_of_skate.rs @@ -1,172 +1,135 @@ -//! The mod is tested through the public Lua VM; no game rules live in the host. -use serde_json::{json, Value}; +//! Exercise independent Lua VMs exchanging public network snapshots. use crate::{vm::Vm, Command, validate_package}; +use serde_json::{json, Value}; use std::{collections::BTreeMap, path::PathBuf}; - -struct Game { vm: Vm, snapshot: Value, state: Value, overlays: BTreeMap } -impl Game { +const MOD: &str = "examples.game-of-skate"; +struct Peer { + id: String, vm: Vm, overlays: BTreeMap, + suspended: bool, camera: Option, teleports: Vec, +} +struct Match { + peers: Vec, wire: Value, skaters: Value, host: String, +} +impl Match { fn new(ids: &[&str]) -> Self { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples/game-of-skate"); - let manifest = validate_package(&root).unwrap(); - let mut snapshot = json!({"player":{},"skaters":{},"network":{"active":ids.len()>1,"local_id":ids[0],"host_id":ids[0],"is_host":true,"players":ids,"states":{}}}); - for id in ids { snapshot["skaters"][*id] = json!({"name":format!("Skater {id}"),"landing_seq":7,"landed_trick":"Old kickflip","trick_seq":100,"trick":"Old kickflip","bail_seq":2,"bailing":false}); } - let vm = Vm::new(&root, &manifest, &BTreeMap::from([("copy_seconds".into(),json!(15)),("allow_ollie".into(),json!(false))]), &snapshot).unwrap(); - let mut g = Self {vm,snapshot,state:Value::Null,overlays:BTreeMap::new()}; - g.vm.call("on_load",json!({}),&g.snapshot).unwrap(); - let start=g.vm.call("on_event",json!({"name":"menu_action","menu":"session","item":"start"}),&g.snapshot).unwrap(); - for c in start {if let Command::NetworkState{value,..}=c {g.state=value;}} - g.tick(0.); + let mut g=Self {peers:vec![],wire:json!({}),skaters:json!({}),host:ids[0].into()}; + let root=PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples/game-of-skate"); + let manifest=validate_package(&root).unwrap(); + for (i,id) in ids.iter().enumerate() { + g.skaters[*id]=json!({"position":[i as f32*10.,2.,3.],"heading":0.7,"name":format!("Skater {id}"),"landing_seq":7,"landed_trick":"Old kickflip","bail_seq":2,"bailing":false}); + } + for id in ids { + let snapshot=json!({"player":g.skaters[*id],"skaters":g.skaters,"network":{"active":ids.len()>1,"local_id":id,"host_id":ids[0],"is_host":*id==ids[0],"players":ids,"states":{}}}); + let vm=Vm::new(&root,&manifest,&BTreeMap::from([("copy_seconds".into(),json!(15)),("allow_ollie".into(),json!(false))]),&snapshot).unwrap(); + g.peers.push(Peer{id:(*id).into(),vm,overlays:BTreeMap::new(),suspended:false,camera:None,teleports:vec![]}); + } + for i in 0..g.peers.len() {g.call(i,"on_load",json!({}));} g } - fn tick(&mut self, dt: f64) { - self.vm.call("on_update",json!({"dt":dt}),&self.snapshot).unwrap(); - let commands = self.vm.call("on_fixed_update",json!({"dt":dt}),&self.snapshot).unwrap(); + fn snapshot(&self,i:usize)->Value { + let id=&self.peers[i].id; + json!({"player":self.skaters[id],"skaters":self.skaters,"network":{"active":self.peers.len()>1,"local_id":id,"host_id":self.host,"is_host":*id==self.host,"players":self.peers.iter().map(|p|&p.id).collect::>(),"states":{MOD:self.wire}}}) + } + fn call(&mut self,i:usize,callback:&str,arg:Value) { + let snapshot=self.snapshot(i); + let commands=self.peers[i].vm.call(callback,arg,&snapshot).unwrap(); for c in commands { - assert!(c.validate()); + assert!(c.validate(),"{c:?}"); + let peer=&mut self.peers[i]; match c { - Command::NetworkState {key,value} if key == "skate" => { assert!(serde_json::to_vec(&value).unwrap().len()<=512); self.state=value; } - Command::Overlay {key,text} => {self.overlays.insert(key,text);} - _ => {} + Command::NetworkState{key,value}=> { + let bytes=serde_json::to_vec(&value).unwrap();assert!(bytes.len()<=512,"{} bytes for {key}",bytes.len()); + self.wire[&peer.id][&key]=serde_json::from_slice(&bytes).unwrap(); + } + Command::PlayerSuspend{suspended}=>peer.suspended=suspended, + Command::CameraWatch{peer:target}=>peer.camera=target, + Command::PlayerTeleport{options}=>peer.teleports.push(options), + Command::Overlay{key,text}=>{peer.overlays.insert(key,text);} + _=>{} } } } - fn land(&mut self, id: &str, name: &str) { - let s = &mut self.snapshot["skaters"][id]; - s["landing_seq"] = json!(s["landing_seq"].as_u64().unwrap()+1); - s["landed_trick"] = json!(name); - s["bailing"] = json!(false); - self.tick(0.02); + fn event(&mut self,i:usize,item:&str){self.call(i,"on_event",json!({"name":"menu_action","menu":"session","item":item}));} + fn step(&mut self) { + for i in 0..self.peers.len(){self.call(i,"on_update",json!({"dt":0.05}));self.call(i,"on_ui_update",json!({}));self.call(i,"on_fixed_update",json!({"dt":0.05}));} } - fn bail(&mut self,id:&str) { - let s=&mut self.snapshot["skaters"][id]; - s["bail_seq"]=json!(s["bail_seq"].as_u64().unwrap()+1); - s["bailing"]=json!(true); - self.tick(0.02); + fn settle(&mut self){for _ in 0..24 {self.step();}} + fn start(&mut self){self.event(0,"start");self.settle();} + fn state(&self)->&Value {&self.wire[&self.host]["skate"]} + fn land(&mut self,id:&str,trick:&str) { + let p=&mut self.skaters[id];p["landing_seq"]=json!(p["landing_seq"].as_u64().unwrap()+1);p["landed_trick"]=json!(trick);p["bailing"]=json!(false);self.step(); } } - #[test] -fn announcements_and_old_landings_do_not_count_and_solo_needs_second_landing() { - let mut g = Game::new(&["0"]); - g.snapshot["skaters"]["0"]["trick_seq"]=json!(101); - g.snapshot["skaters"]["0"]["trick"]=json!("Kickflip"); - g.tick(1.); - assert_eq!(g.state["p"],"s"); - g.land("0","Kickflip"); - assert_eq!(g.state["p"],"c"); - assert_eq!(g.state["d"],"0"); - g.tick(1.); - assert_eq!(g.state["p"],"c"); - g.land("0","Kickflip"); - assert_eq!(g.state["p"],"r"); - assert_eq!(g.state["d"],"1"); - assert!(g.overlays["skate_status"].starts_with("Matched")); - g.tick(3.); - assert_eq!(g.state["p"],"s"); - assert_eq!(g.state["n"],2); +fn independent_clients_receive_host_first_turn_and_mirror_active_camera() { + let mut g=Match::new(&["99","1","2"]);g.start(); + assert_eq!(g.wire["99"]["origin"]["ids"],json!(["99","1","2"])); + assert_eq!(g.state()["p"],"s");assert_eq!(g.state()["c"],1); + assert!(!g.peers[0].suspended);assert!(g.peers[1].suspended && g.peers[2].suspended); + assert_eq!(g.peers[1].camera.as_deref(),Some("99")); + assert!(g.peers[1].overlays["skate_status"].contains("Skater 99: land a trick")); + g.land("99","Kickflip");g.settle(); + assert_eq!(g.state()["p"],"c");assert_eq!(g.state()["c"],2); + assert!(g.peers[0].suspended && g.peers[2].suspended);assert!(!g.peers[1].suspended); + assert_eq!(g.peers[0].camera.as_deref(),Some("1"));assert_eq!(g.peers[1].camera,None); + assert!(g.peers[1].overlays["skate_status"].contains("You: copy Kickflip")); + assert_eq!(g.peers[1].teleports.last().unwrap().position,[0.,2.,3.]); + assert_eq!(g.peers[1].teleports.last().unwrap().heading,Some(0.7)); + g.land("1","Kickflip");g.settle();assert_eq!(g.state()["c"],3); + assert_eq!(g.state()["d"],"010");assert_eq!(g.peers[0].camera.as_deref(),Some("2")); + assert_eq!(g.peers[2].teleports.last().unwrap().position,[0.,2.,3.]); + g.land("2","Heelflip");assert_eq!(g.state()["p"],"r");assert_eq!(g.state()["l"],"001"); } - #[test] -fn wrong_trick_bail_and_timeout_each_award_only_one_letter() { - let mut g = Game::new(&["0"]); - g.land("0","Kickflip"); - g.land("0","Heelflip"); - assert_eq!(g.state["l"],"1"); - g.tick(3.); - g.land("0","Kickflip"); - g.bail("0"); - assert_eq!(g.state["l"],"2"); - g.tick(1.); - assert_eq!(g.state["l"],"2"); - g.tick(2.); - g.land("0","Kickflip"); - g.tick(16.); - assert_eq!(g.state["l"],"3"); +fn pre_turn_results_announcements_and_old_landings_cannot_complete_a_turn() { + let mut g=Match::new(&["99","1"]);g.start(); + g.skaters["99"]["trick"]=json!("Kickflip");g.step();assert_eq!(g.state()["p"],"s"); + g.land("1","Kickflip");g.land("99","Kickflip");g.settle();assert_eq!(g.state()["p"],"c");assert_eq!(g.state()["d"],"00"); + g.land("1","Kickflip");assert_eq!(g.state()["p"],"r");assert_eq!(g.state()["d"],"01"); } - #[test] -fn remote_pre_turn_results_are_consumed_and_remote_bails_are_not_missed() { - let mut g=Game::new(&["1","2","3"]); - g.land("2","Kickflip"); - g.land("1","Kickflip"); - g.tick(1.); - assert_eq!(g.state["d"],"000"); - g.land("2","Kickflip"); - assert_eq!(g.state["d"],"010"); - // A remote can already be standing again when the latest packet arrives. - g.snapshot["skaters"]["3"]["bail_seq"]=json!(3); - g.tick(0.02); - assert_eq!(g.state["p"],"r"); - assert_eq!(g.state["l"],"001"); - g.tick(3.); - assert_eq!(g.state["a"],"2"); - assert_eq!(g.state["p"],"s"); +fn solo_resets_between_setting_and_copying_and_needs_a_second_landing() { + let mut g=Match::new(&["0"]);g.start();g.land("0","Kickflip");g.settle(); + assert_eq!(g.state()["p"],"c");assert_eq!(g.state()["d"],"0");assert_eq!(g.peers[0].teleports.len(),2); + g.land("0","Kickflip");assert_eq!(g.state()["p"],"r");assert_eq!(g.state()["d"],"1"); } - #[test] -fn ten_player_state_fits_lua_network_budget() { - let ids: Vec<_>=(0..10).map(|i| format!("1844674407370955160{i}")).collect(); - let refs: Vec<_>=ids.iter().map(String::as_str).collect(); - let mut g=Game::new(&refs); +fn bail_and_timeout_award_one_letter_to_the_active_copier_only() { + let mut g=Match::new(&["99","1","2"]);g.start();g.land("99","Kickflip");g.settle(); + g.skaters["1"]["bail_seq"]=json!(3);g.step();g.settle(); + assert_eq!(g.state()["l"],"010");assert_eq!(g.state()["c"],3); + for _ in 0..310 {g.step();} + assert_eq!(g.state()["l"],"011");assert_eq!(g.state()["p"],"r"); +} +#[test] +fn stop_unload_and_disconnect_restore_player_and_camera() { + let mut g=Match::new(&["99","1"]);g.start();g.event(1,"stop");g.step();assert_eq!(g.state()["p"],"s"); + g.event(0,"stop");g.step(); + for p in &g.peers{assert!(!p.suspended);assert!(p.camera.is_none());} + g.start();g.call(1,"on_unload",json!({}));assert!(!g.peers[1].suspended);assert!(g.peers[1].camera.is_none()); +} +#[test] +fn absent_acknowledgement_blocks_play_and_reports_the_missing_setup() { + let mut g=Match::new(&["99","1"]);g.event(0,"start"); + for _ in 0..320 {g.call(0,"on_update",json!({"dt":0.05}));g.call(0,"on_fixed_update",json!({"dt":0.05}));} + assert_eq!(g.state()["p"],"i");assert!(g.state()["error"].as_str().unwrap().contains("acknowledge"));assert!(!g.peers[0].suspended); +} +#[test] +fn ten_large_peer_ids_and_long_tricks_fit_each_network_record() { + let ids:Vec<_>=(0..10).map(|i|format!("1844674407370955160{i}")).collect(); + let refs:Vec<_>=ids.iter().map(String::as_str).collect();let mut g=Match::new(&refs);g.start(); g.land(refs[0],&"x".repeat(64)); - for id in &refs[1..] { g.land(id,&"x".repeat(64)); } - assert_eq!(g.state["p"],"r"); + for id in &refs[1..]{g.settle();g.land(id,&"x".repeat(64));} + assert_eq!(g.state()["p"],"r"); } #[test] -fn departing_setter_is_replaced_and_new_peer_old_landing_is_ignored() { - let mut g=Game::new(&["1","2","3"]); - g.snapshot["network"]["players"]=json!(["1","3"]); - g.land("1","Kickflip"); - g.snapshot["network"]["players"]=json!(["1","2","3"]); - g.tick(1.); - assert_eq!(g.state["d"],"000"); - g.snapshot["network"]["players"]=json!(["2","3"]); - g.snapshot["network"]["local_id"]=json!("2"); - g.tick(1.); - assert_eq!(g.state["p"],"s"); - assert_eq!(g.state["a"],"2"); -} - -#[test] -fn menu_start_stop_is_explicit_and_clients_cannot_start_the_host_game() { - let mut g=Game::new(&["1","2"]); - g.vm.call("on_event",json!({"name":"menu_action","menu":"session","item":"stop"}),&g.snapshot).unwrap(); - g.tick(2.); - assert_eq!(g.state["p"],"i"); - g.land("1","Kickflip"); - assert_eq!(g.state["p"],"i"); - g.vm.call("on_event",json!({"name":"menu_action","menu":"session","item":"start"}),&g.snapshot).unwrap(); - g.tick(1.1); - assert_eq!(g.state["p"],"s"); - g.snapshot["network"]["is_host"]=json!(false); - let commands=g.vm.call("on_event",json!({"name":"menu_action","menu":"session","item":"start"}),&g.snapshot).unwrap(); - assert!(!commands.iter().any(|c|matches!(c,Command::NetworkState{..}))); -} - -#[test] -fn paused_ui_refreshes_client_host_and_solo_actions_without_physics_ticks() { - let mut g=Game::new(&["1","2"]); - g.snapshot["paused"]=json!(true); - g.snapshot["network"]["is_host"]=json!(false); - let menu = |commands: Vec| commands.into_iter().find_map(|c| match c { - Command::UiMenu{options,..} => Some(options), _=>None - }).expect("menu refreshed"); - let client=menu(g.vm.call("on_ui_update",json!({"dt":10.,"paused":true}),&g.snapshot).unwrap()); - assert_eq!(client.section.as_deref(),Some("Gamemodes")); - assert_eq!(client.title,"SKATE"); - assert!(!client.items[0].enabled && !client.items[1].enabled); - assert!(client.items[0].description.contains("Only the multiplayer host")); - g.snapshot["network"]["is_host"]=json!(true); - let host=menu(g.vm.call("on_ui_update",json!({"dt":10.,"paused":true}),&g.snapshot).unwrap()); - assert!(host.items[0].enabled && !host.items[1].enabled); - let started=menu(g.vm.call("on_event",json!({"name":"menu_action","menu":"session","item":"start"}),&g.snapshot).unwrap()); - assert!(!started.items[0].enabled && started.items[1].enabled); - let stopped=menu(g.vm.call("on_event",json!({"name":"menu_action","menu":"session","item":"stop"}),&g.snapshot).unwrap()); - assert!(stopped.items[0].enabled && !stopped.items[1].enabled); - g.snapshot["network"]["is_host"]=json!(false); - g.vm.call("on_ui_update",json!({"dt":1.,"paused":true}),&g.snapshot).unwrap(); - g.snapshot["network"]["active"]=json!(false); - let solo=menu(g.vm.call("on_ui_update",json!({"dt":1.,"paused":true}),&g.snapshot).unwrap()); - assert!(solo.items[0].enabled && !solo.items[1].enabled); +fn late_joiners_spectate_and_disconnected_copiers_do_not_block_the_round() { + let mut g=Match::new(&["99","1","2"]); + let late=g.peers.pop().unwrap();g.start();g.peers.push(late);g.settle(); + assert_eq!(g.wire["99"]["origin"]["ids"],json!(["99","1"])); + assert!(g.peers[2].suspended);assert_eq!(g.peers[2].camera.as_deref(),Some("99")); + g.land("99","Kickflip");g.settle();g.peers.remove(1);g.step(); + assert_eq!(g.state()["p"],"r"); + g.event(0,"stop");g.step();assert!(g.peers.iter().all(|p|!p.suspended)); } diff --git a/crates/skate-mods/src/tests/general_api.rs b/crates/skate-mods/src/tests/general_api.rs new file mode 100644 index 00000000..ff93c4c0 --- /dev/null +++ b/crates/skate-mods/src/tests/general_api.rs @@ -0,0 +1,316 @@ +use crate::{Command, Manifest, validate_package, vm::Vm}; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, path::PathBuf}; +struct Bones { + vm: Vm, + snapshot: Value, + commands: Vec, + tick: u64, +} +impl Bones { + fn new(settings: Value) -> Self { + let root = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples/broken-bones"); + let manifest = validate_package(&root).unwrap(); + let mut options: BTreeMap<_, _> = manifest + .settings + .iter() + .map(|(k, v)| (k.clone(), v.default.clone())) + .collect(); + options.extend( + settings + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.clone())), + ); + let snapshot = json!({"player":{"mode":"ground","bailing":false},"player_physics":{"tick":1,"joints":[{"index":3,"name":"JOINT_LEFT_ARM_FOREARM","parent":5,"child":4,"override_owner":null}],"contacts":[],"parts":[]}}); + let vm = Vm::new(&root, &manifest, &options, &snapshot).unwrap(); + let mut h = Self { + vm, + snapshot, + commands: vec![], + tick: 1, + }; + h.call("on_load", json!({})); + h.commands.clear(); + h + } + fn call(&mut self, name: &str, arg: Value) { + let commands = self.vm.call(name, arg, &self.snapshot).unwrap(); + assert!(commands.iter().all(Command::validate)); + self.commands.extend(commands); + } + fn step(&mut self) { + self.tick += 1; + self.snapshot["player_physics"]["tick"] = json!(self.tick); + self.call("on_update", json!({"dt":0.05})); + self.call("on_fixed_update", json!({"dt":0.05})); + } + fn contact(&mut self, speed: f32, impulse: f32) { + self.snapshot["player_physics"]["contacts"] = json!([{"id":"4/world","phase":"begin","a":{"kind":"skater","index":4},"b":{"kind":"world"},"closing_speed":speed,"impulse":[0.,impulse,0.],"force":[0.,999999.,0.]}]); + } + fn requests(&self) -> Vec<&Command> { + self.commands + .iter() + .filter(|c| matches!(c, Command::Request { .. })) + .collect() + } + fn acknowledge(&mut self, ok: bool) { + let (key, token) = self + .commands + .iter() + .rev() + .find_map(|c| { + if let Command::Request { key, token, .. } = c { + Some((key.clone(), *token)) + } else { + None + } + }) + .unwrap(); + self.snapshot["command_results"] = json!({"examples.broken-bones":{key:{"token":token,"ok":ok,"error":if ok{Value::Null}else{json!("owned by another mod")}}}}); + } +} +#[test] +fn bones_require_both_closing_speed_and_impulse_not_support_force() { + let mut h = Bones::new(json!({})); + h.contact(0., 1000.); + h.step(); + assert!(h.requests().is_empty()); + h.contact(9., 10.); + h.step(); + assert!(h.requests().is_empty()); + h.contact(9., 100.); + h.step(); + assert_eq!(h.requests().len(), 1); + match h.requests()[0] { + Command::Request { command, .. } => match &**command { + Command::PlayerJoint { joint, options } => { + assert_eq!(*joint, 3); + assert_eq!(options.drive_enabled, Some(false)); + assert_eq!(options.possession_enabled, Some(false)); + assert!(options.descendants); + assert_ne!(options.enabled, Some(false)); + } + _ => panic!(), + }, + _ => panic!(), + } + h.acknowledge(true); + h.commands.clear(); + h.step(); + assert!(h.requests().is_empty()); + assert!(h.commands.iter().any(|c|matches!(c,Command::NetworkState{key,value} if key=="injuries" && value["j"]==json!([3])))); + h.commands.clear(); + h.call( + "on_event", + json!({"name":"menu_action","menu":"bones","item":"heal"}), + ); + assert!( + h.commands + .iter() + .any(|c| matches!(c, Command::PlayerResetJoint { joint: 3 })) + ); +} +#[test] +fn bones_wait_for_bail_and_respect_other_mod_joint_ownership() { + let mut h = Bones::new(json!({"only_bails":true})); + h.contact(9., 100.); + h.step(); + assert!(h.requests().is_empty()); + h.snapshot["player_physics"]["contacts"] = json!([]); + h.snapshot["player"]["bailing"] = json!(true); + h.step(); + assert_eq!(h.requests().len(), 1); + let mut h = Bones::new(json!({})); + h.snapshot["player_physics"]["joints"][0]["override_owner"] = json!("another.mod"); + h.contact(9., 100.); + h.step(); + assert!(h.requests().is_empty()); +} +#[test] +fn bones_failed_override_is_reported_without_claiming_an_injury() { + let mut h = Bones::new(json!({})); + h.contact(9., 100.); + h.step(); + h.acknowledge(false); + h.commands.clear(); + h.snapshot["player_physics"]["contacts"] = json!([]); + h.step(); + assert!( + !h.commands + .iter() + .any(|c| matches!(c,Command::NetworkState{value,..} if value["j"]==json!([3]))) + ); + assert!( + h.commands.iter().any( + |c| matches!(c,Command::Overlay{text,..} if text.contains("owned by another mod")) + ) + ); +} +#[test] +fn generalized_commands_reject_invalid_refs_and_nested_requests() { + for v in [ + json!({"kind":"native_impulse","body":{"kind":"remote","index":0},"impulse":[1,0,0],"angular":false}), + json!({"kind":"input_override","action":1,"value":0}), + json!({"kind":"graph_gate","graph":"motion","target":"raw_memory","index":1,"enabled":false}), + json!({"kind":"request","key":"outer","command":{"kind":"request","key":"inner","command":{"kind":"log","text":"x"}}}), + ] { + let c: Command = serde_json::from_value(v).unwrap(); + assert!(!c.validate()); + } +} +#[test] +fn generalized_wrappers_route_typed_bodies_and_ignore_stale_receipts() { + let root = std::env::temp_dir().join(format!("skate-general-api-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("main.lua"), + r#" + return { + on_load=function() + sdk.bodies.impulse({kind='skater',index=4},{1,2,3},{0,1,0}) + sdk.bodies.impulse({kind='mod',key='car'},{1,0,0}) + sdk.input.override_action(64,0.5) + sdk.input.override_action(64,nil) + sdk.graphs.set_enabled('motion','state',3,false) + sdk.engine.inspect('catalog','scoring') + sdk.commands.request('move',{kind='log',text='one'}) + end, + on_event=function() sdk.commands.request('move',{kind='log',text='two'}) end, + on_fixed_update=function() assert(sdk.commands.result('move')==nil) end + }"#, + ) + .unwrap(); + let manifest:Manifest=serde_json::from_value(json!({"id":"tests.general","api":2,"name":"test","version":"1","author":"test","description":"test","entry":"main.lua"})).unwrap(); + let mut vm = Vm::new(&root, &manifest, &BTreeMap::new(), &json!({})).unwrap(); + let commands = vm.call("on_load", json!({}), &json!({})).unwrap(); + assert!(commands.iter().all(Command::validate)); + assert!(matches!(commands[0], Command::NativeImpulse { .. })); + assert!(matches!(commands[1], Command::PhysicsImpulse { .. })); + let old = commands + .iter() + .find_map(|c| { + if let Command::Request { key, token, .. } = c { + (key == "move").then_some(*token) + } else { + None + } + }) + .unwrap(); + vm.call("on_event", json!({}), &json!({})).unwrap(); + vm.call( + "on_fixed_update", + json!({}), + &json!({"command_results":{"tests.general":{"move":{"token":old,"ok":true}}}}), + ) + .unwrap(); + std::fs::remove_file(root.join("main.lua")).unwrap(); + std::fs::remove_dir(root).unwrap(); +} + +struct FlatGround; +impl crate::DynamicsHost for FlatGround { + fn raycast(&mut self, o: [f32; 3], _: [f32; 3], _: &crate::RaycastOptions) -> Option { + Some(json!({"point":[o[0],0.,o[2]],"normal":[0.,1.,0.],"distance":o[1]})) + } + fn velocity_at(&self, _: &str, _: [f32; 3]) -> Option<[f32; 3]> { + Some([0.; 3]) + } + fn effective_inv_mass(&self, _: &str, _: [f32; 3], _: [f32; 3]) -> Option { + Some(0.001) + } + fn spring_ray( + &mut self, + _: &str, + _: skate_dynamics::SpringRayDesc, + ) -> Option { + None + } + fn local_ang_accel_impulse(&self, _: &str, _: [f32; 3], _: f32) -> Option<[f32; 3]> { + Some([0.; 3]) + } +} +#[test] +fn skyline_waits_for_spawn_receipt_and_reports_failure_without_disabling_mod() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../mods/Skyline_Drive_Mod"); + let manifest = validate_package(&root).unwrap(); + let options = manifest + .settings + .iter() + .map(|(k, s)| (k.clone(), s.default.clone())) + .collect(); + let mut s = json!({"player":{"position":[0.,1.,0.],"heading":0.},"physics":{"bodies":{}},"keys":{"F10":true}}); + let mut vm = Vm::new(&root, &manifest, &options, &s).unwrap(); + let mut call = |name: &str, s: &Value| { + crate::with_host(&mut FlatGround, || vm.call(name, json!({"dt":0.05}), s)).unwrap() + }; + assert!(call("on_load", &s).iter().all(Command::validate)); + // F10 removes old resources; spawning happens the following fixed tick. + call("on_fixed_update", &s); + s["keys"]["F10"] = json!(false); + let cmds = call("on_fixed_update", &s); + let token = cmds + .iter() + .find_map(|c| { + if let Command::Request { + key, + token, + command, + } = c + { + assert!(matches!(&**command, Command::PhysicsSpawn { .. })); + (key == "spawn_chassis").then_some(*token) + } else { + None + } + }) + .expect("spawn request"); + for _ in 0..12 { + let cmds = call("on_fixed_update", &s); + assert!( + !cmds + .iter() + .any(|c| matches!(c,Command::GraphicsMesh{key,..} if key=="skyline_visual")) + ); + } + s["command_results"] = json!({manifest.id.clone():{"spawn_chassis":{"token":token,"ok":false,"error":"collision model unavailable"}}}); + let cmds = call("on_fixed_update", &s); + assert!( + cmds.iter().any( + |c| matches!(c,Command::Log{text} if text.contains("collision model unavailable")) + ) + ); + assert!( + !cmds + .iter() + .any(|c| matches!(c,Command::GraphicsMesh{key,..} if key=="skyline_visual")) + ); + // Retry must ignore the old failure receipt, and bind graphics only after success. + s["keys"]["F10"] = json!(true); + call("on_fixed_update", &s); + s["keys"]["F10"] = json!(false); + let cmds = call("on_fixed_update", &s); + let new_token = cmds + .iter() + .find_map(|c| { + if let Command::Request { token, .. } = c { + Some(*token) + } else { + None + } + }) + .unwrap(); + assert_ne!(token, new_token); + call("on_fixed_update", &s); + s["command_results"][&manifest.id]["spawn_chassis"] = json!({"token":new_token,"ok":true}); + s["physics"]["bodies"]["chassis"] = json!({"position":[0.,1.,4.5],"rotation":[0.,0.,0.,1.],"linvel":[0.,0.,0.],"angvel":[0.,0.,0.]}); + let cmds = call("on_fixed_update", &s); + assert!(cmds.iter().all(Command::validate)); + assert!( + cmds.iter() + .any(|c| matches!(c,Command::GraphicsMesh{key,..} if key=="skyline_visual")) + ); + call("on_unload", &s); +} diff --git a/crates/skate-mods/src/tests/skyline_drive.rs b/crates/skate-mods/src/tests/skyline_drive.rs new file mode 100644 index 00000000..9336f826 --- /dev/null +++ b/crates/skate-mods/src/tests/skyline_drive.rs @@ -0,0 +1,151 @@ +//! Current installed DRIVE mod, real Lua, cooked render-model colliders and Rapier. +//! This is a physics/API regression check, not an FPS or WAN latency benchmark. +use crate::{Command, DynamicsHost, RaycastFilter, RaycastOptions, vm::Vm}; +use serde_json::{Value, json}; +use skate_dynamics::{DynamicsWorld, Shape, definition_codec, solid::sweep_sphere}; +use std::{collections::BTreeMap, path::PathBuf}; + +const MOD: &str = "examples.skyline"; +const DT: f32 = 1. / 120.; + +struct Host<'a> { world: &'a mut DynamicsWorld, car: Option } +impl DynamicsHost for Host<'_> { + fn raycast(&mut self, origin:[f32;3], direction:[f32;3], options:&RaycastOptions)->Option { + let hit=match options.filter { + RaycastFilter::Ground=>self.world.raycast_ground(origin,direction,options.max_distance), + RaycastFilter::All=>self.world.raycast_excluding_bodies(origin,direction,options.max_distance, + &self.car.filter(|_|options.exclude.iter().any(|k|k=="chassis")).into_iter().collect::>()), + }?; + Some(json!({"body":if Some(hit.body)==self.car {"chassis"} else {"ground"}, + "point":hit.point,"normal":hit.normal,"toi":hit.toi})) + } + fn velocity_at(&self,key:&str,point:[f32;3])->Option<[f32;3]> { + if key=="ground" {Some([0.;3])} else {self.world.velocity_at(self.car?,point)} + } + fn effective_inv_mass(&self,_:&str,p:[f32;3],d:[f32;3])->Option { + self.world.effective_inv_mass(self.car?,p,d) + } + fn spring_ray(&mut self,_:&str,d:skate_dynamics::SpringRayDesc)->Option { + self.world.spring_ray(self.car?,d) + } + fn local_ang_accel_impulse(&self,_:&str,a:[f32;3],dt:f32)->Option<[f32;3]> { + self.world.local_ang_accel_impulse(self.car?,a,dt) + } +} + +struct Drive { + vm:Vm, world:DynamicsWorld, root:PathBuf, car:Option, + receipt:Value, tick:u64, forces:usize, +} +impl Drive { + fn new() -> Self { + let root=PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../mods/Skyline_Drive_Mod"); + let manifest=crate::validate_package(&root).expect("installed Skyline package"); + let settings=manifest.settings.iter().map(|(k,v)|(k.clone(),v.default.clone())).collect::>(); + let vm=Vm::new(&root,&manifest,&settings,&Value::Null).unwrap(); + let mut world=DynamicsWorld::default(); + world.set_ground([ + [[-500.,0.,-500.],[500.,0.,-500.],[500.,0.,500.]], + [[-500.,0.,-500.],[500.,0.,500.],[-500.,0.,500.]], + ].into_iter()).unwrap(); + let mut drive=Self {vm,world,root,car:None,receipt:Value::Null,tick:0,forces:0}; + drive.call("on_load",json!({}),json!({}),false); + drive + } + fn snapshot(&self,keys:Value,occupied:bool)->Value { + let mut bodies=json!({}); + if let Some(id)=self.car { + let b=self.world.read(id).unwrap(); + bodies["chassis"]=json!({"position":b.position,"rotation":b.rotation, + "linvel":b.linvel,"angvel":b.angvel,"mass":b.mass,"force":b.force,"torque":b.torque}); + } + json!({"tick":self.tick,"keys":keys,"paused":false,"replay":false, + "player":{"position":[0.,0.35,0.],"heading":0.,"velocity":[0.,0.,0.]}, + "map":{"generation":1},"pad":{"buttons":0,"triggers":[0.,if occupied {1.} else {0.}],"left":[0.,0.],"right":[0.,0.]}, + "physics":{"bodies":bodies,"contacts":[],"touching":[]}, + "attach":if occupied {json!({"owner":MOD,"body":"chassis"})} else {Value::Null}, + "command_results":{MOD:{"spawn_chassis":self.receipt}}, + "network":{"active":true,"is_host":true,"local_id":"1","players":["1","2"],"states":{},"status":"test"}}) + } + fn call(&mut self,name:&str,payload:Value,keys:Value,occupied:bool) { + let snapshot=self.snapshot(keys,occupied); + let mut host=Host {world:&mut self.world,car:self.car}; + let commands=crate::with_host(&mut host,||self.vm.call(name,payload,&snapshot)).unwrap(); + for command in commands {assert!(command.validate());self.apply(command);} + } + fn apply(&mut self,command:Command) { + match command { + Command::Request{key,token,command}=>{ + assert_eq!(key,"spawn_chassis");self.apply(*command); + self.receipt=json!({"ok":true,"token":token,"tick":self.tick}); + } + Command::PhysicsSpawn{key,mut body}=>{ + assert_eq!(key,"chassis"); + if let Shape::Model{path,object,options}=&body.shape { + body.shape=crate::model_shape_file(&self.root.join(path),object,options).unwrap(); + } + self.car=Some(self.world.spawn(body).unwrap()); + } + Command::PhysicsRemove{..}=>{if let Some(id)=self.car.take(){self.world.remove(id);}}, + Command::PhysicsForce{force,point,..}=>{ + assert!(self.world.apply_force(self.car.unwrap(),force,point));self.forces+=1; + } + Command::PhysicsTorque{torque,..}=>{assert!(self.world.apply_torque(self.car.unwrap(),torque));}, + Command::PhysicsImpulse{impulse,point,..}=>{assert!(self.world.apply_impulse(self.car.unwrap(),impulse,point));}, + Command::PhysicsTorqueImpulse{torque,..}=>{assert!(self.world.apply_torque_impulse(self.car.unwrap(),torque));}, + Command::PhysicsDebug{..}=>{}, + // Rendering/audio/UI commands do not participate in this physics test. + other=>assert!(!format!("{other:?}").starts_with("Physics"),"unhandled physics command: {other:?}"), + } + } + fn step(&mut self,keys:Value,occupied:bool,renders:usize) { + self.tick+=1; + for _ in 0..renders { + self.call("on_ui_update",json!({"dt":DT/renders as f32}),keys.clone(),occupied); + self.call("on_update",json!({"dt":DT/renders as f32}),keys.clone(),occupied); + } + self.world.begin_force_frame(); + self.call("on_fixed_update",json!({"dt":DT}),keys,occupied); + self.world.step(DT); + } +} + +#[test] +fn installed_drive_preserves_physics_across_render_rates_and_replica_geometry() { + let mut slow=Drive::new();let mut fast=Drive::new(); + for drive in [&mut slow,&mut fast] { + drive.step(json!({"F10":true}),false,1); + drive.step(json!({}),false,1); + assert!(drive.car.is_some(),"real Skyline spawn command must succeed"); + } + for tick in 0..360 { + // Settle first; afterwards the same driver state at 120 and 480 render FPS. + let occupied=tick>=120; + slow.step(json!({}),occupied,1);fast.step(json!({}),occupied,4); + let a=slow.world.read(slow.car.unwrap()).unwrap(); + let b=fast.world.read(fast.car.unwrap()).unwrap(); + for (x,y) in a.position.iter().zip(b.position.iter()).chain(a.linvel.iter().zip(b.linvel.iter())) { + assert!(x.is_finite() && y.is_finite() && (x-y).abs()<1e-4,"render-dependent physics at tick {tick}: {a:?} vs {b:?}"); + } + } + let id=fast.car.unwrap();let car=fast.world.read(id).unwrap(); + assert!(fast.forces>300 && car.linvel[2]>1.,"current car must actually drive: {car:?}"); + assert!(car.position[1]>0.2,"car must stay supported by real ground"); + let definition=fast.world.body_definition(id).unwrap(); + assert!(definition.body.ccd && definition.body.contact_group==8); + assert!(matches!(definition.body.shape,Shape::Compound{..})); + let wire=definition_codec::encode(&definition).unwrap(); + let decoded=definition_codec::decode(&wire).unwrap(); + let mut remote=DynamicsWorld::default();let replica=remote.spawn_replica(&decoded).unwrap(); + remote.set_pose(replica,car.position,car.rotation); + remote.set_kinematic_motion(replica,car.linvel,car.angvel); + let local=fast.world.solid_bodies();let mirrored=remote.solid_bodies(); + assert_eq!(local[0].colliders.len(),mirrored[0].colliders.len()); + assert!((local[0].inverse_mass-mirrored[0].inverse_mass).abs()<1e-7); + // A rapid sweep through the actual car must hit the same surface on each peer. + let [x,y,z]=car.position; + let a=sweep_sphere(&local,[x-5.,y,z],[x+5.,y,z],0.3).expect("local car collision").1; + let b=sweep_sphere(&mirrored,[x-5.,y,z],[x+5.,y,z],0.3).expect("replica collision").1; + assert!((a.time_of_impact-b.time_of_impact).abs()<1e-5); + assert!(!remote.add_velocity_delta(replica,[10.,0.,0.],[0.;3]),"remote owner must not receive a duplicate reaction"); +} diff --git a/crates/skate-mods/src/vm.rs b/crates/skate-mods/src/vm.rs index 16e709a5..ef150efe 100644 --- a/crates/skate-mods/src/vm.rs +++ b/crates/skate-mods/src/vm.rs @@ -119,6 +119,7 @@ pub enum Command { key: String, text: String, }, + MultiplayerDebug { key: String, text: String }, UiCanvas { key: String, options: crate::presentation::CanvasOptions }, UiRemove { key: String }, CameraRig { body: String, options: crate::presentation::CameraRigOptions }, @@ -291,9 +292,16 @@ pub enum Command { }, UiMenu { key:String, options:crate::extensions::MenuOptions }, UiRemoveMenu { key:String }, + RigPart {index:usize, options:Option}, + GraphGate { graph:String, target:String, index:usize, enabled:Option }, + EngineInspect { system:String }, + Request { key:String, #[serde(default)] token:u64, command:Box }, + InputOverride { action:usize, value:Option }, + NativeImpulse { body:crate::extensions::NativeBodyRef, impulse:[f32;3], point:Option<[f32;3]>, angular:bool }, PlayerJoint { joint:usize, options:crate::extensions::JointOverride }, PlayerResetJoint { joint:usize }, PlayerResetJoints {}, + PlayerSuspend { suspended: bool }, PlayerTeleport { options: TeleportOptions, }, @@ -352,8 +360,14 @@ impl Command { let vec3 = |p: &[f32; 3]| p.iter().all(|v| v.is_finite()); let quat = crate::scene::valid_quaternion; match self { + Self::RigPart {index,options} => *index<26 && options.as_ref().is_none_or(|o|o.validate()), + Self::GraphGate {graph,target,index,..} => matches!(graph.as_str(),"action"|"motion") && matches!(target.as_str(),"state"|"transition"|"behavior") && *index<65536, + Self::EngineInspect {system} => matches!(system.as_str(),"graphs"|"scoring"), + Self::Request {key,command,token} => *token<=9_007_199_254_740_991 && crate::schema::valid_id(key) && !matches!(**command,Self::Request{..}) && command.validate(), + Self::InputOverride {action,value} => (64..=81).contains(action) && value.is_none_or(|v|v.is_finite() && (-1.0..=1.0).contains(&v)), + Self::NativeImpulse {body,impulse,point:p,..} => body.validate() && impulse.iter().all(|v|v.is_finite() && v.abs()<=100_000.) && p.as_ref().is_none_or(point), Self::Log { text } => text.len() <= 2048, - Self::Overlay { key, text } => crate::schema::valid_id(key) && text.len() <= 1024, + Self::Overlay { key, text } | Self::MultiplayerDebug { key, text } => crate::schema::valid_id(key) && text.len() <= 1024, Self::UiCanvas { key, options } => crate::schema::valid_id(key) && options.validate(), Self::UiRemove { key } => crate::schema::valid_id(key), Self::CameraRig { body, options } => crate::schema::valid_id(body) && options.validate(), @@ -574,6 +588,7 @@ impl Command { Self::PlayerJoint {joint,options} => *joint < 22 && options.validate(), Self::PlayerResetJoint {joint} => *joint < 22, Self::PlayerResetJoints {} => true, + Self::PlayerSuspend { .. } => true, Self::PlayerTeleport { options } => options.validate(), Self::SessionClaim {} => true, Self::SessionTransfer { peer } => valid_peer(peer), @@ -592,8 +607,15 @@ impl Command { /// null to a null *userdata*, which is truthy and blows up on indexing. fn command_kind(command: &Command) -> &'static str { match command { + Command::RigPart {..} => "rig_part", + Command::GraphGate {..} => "graph_gate", + Command::EngineInspect {..} => "engine_inspect", + Command::Request {..} => "request", + Command::InputOverride {..} => "input_override", + Command::NativeImpulse {..} => "native_impulse", Command::Log { .. } => "log", Command::Overlay { .. } => "overlay", + Command::MultiplayerDebug { .. } => "multiplayer_debug", Command::UiCanvas { .. } => "ui_canvas", Command::UiRemove { .. } => "ui_remove", Command::CameraRig { .. } => "camera_rig", @@ -639,6 +661,7 @@ fn command_kind(command: &Command) -> &'static str { Command::PlayerJoint { .. } => "player_joint", Command::PlayerResetJoint { .. } => "player_reset_joint", Command::PlayerResetJoints {} => "player_reset_joints", + Command::PlayerSuspend { .. } => "player_suspend", Command::PlayerTeleport { .. } => "player_teleport", Command::SessionClaim { .. } => "session_claim", Command::SessionTransfer { .. } => "session_transfer", @@ -754,14 +777,14 @@ fn json_to_lua(lua: &Lua, value: &Value) -> mlua::Result { return Ok(mlua::Value::Nil); } if let Some(obj) = value.as_object() { - let t = lua.create_table()?; + let t = lua.create_table_with_capacity(0, obj.len())?; for (k, v) in obj { t.set(k.clone(), json_to_lua(lua, v)?)?; } return Ok(mlua::Value::Table(t)); } if let Some(arr) = value.as_array() { - let t = lua.create_table()?; + let t = lua.create_table_with_capacity(arr.len(), 0)?; for (i, v) in arr.iter().enumerate() { t.set(i + 1, json_to_lua(lua, v)?)?; } @@ -770,6 +793,42 @@ fn json_to_lua(lua: &Lua, value: &Value) -> mlua::Result { lua.to_value(value) } +// Only a root field that Lua reads is converted. Returned field tables are +// ordinary complete Lua tables, preserving length, pairs and serde behavior. +#[cfg(test)] +fn lazy_snapshot(lua:&Lua, snapshot:Arc, physics:Option)->mlua::Result { + lazy_snapshot_fields(lua, snapshot, physics, crate::SnapshotFields::default()) +} + +fn lazy_snapshot_fields(lua:&Lua, snapshot:Arc, physics:Option, fields:crate::SnapshotFields)->mlua::Result
{ + let defaults=std::sync::LazyLock::force(&SNAPSHOT_DEFAULTS); + let table=lua.create_table()?; + if let Some(physics)=physics {table.raw_set("physics",json_to_lua(lua,&physics)?)?;} + let meta=lua.create_table()?; + let index_snapshot=snapshot.clone(); + let index_fields=fields.clone(); + meta.set("__index",lua.create_function(move |lua,(table,key):(Table,String)| { + let value=index_fields.get(&key).map(|v|v.as_ref()).or_else(||index_snapshot.get(&key)).filter(|v|!v.is_null()).or_else(||SNAPSHOT_DEFAULTS.get(&key)); + let value=value.map_or(Ok(mlua::Value::Nil),|v|json_to_lua(lua,v))?; + table.raw_set(key,value.clone())?; + Ok(value) + })?)?; + // pairs(snapshot) explicitly requests every root field. + let next:mlua::Function=lua.globals().get("next")?; + meta.set("__pairs",lua.create_function(move |_,table:Table| { + // Most callbacks read a few named fields. Build the root-key union + // only when iteration actually requests the complete snapshot. + let keys=snapshot.as_object().into_iter().flat_map(|o|o.keys()) + .chain(fields.keys()).chain(defaults.as_object().unwrap().keys()) + .collect::>(); + for key in keys {let _:mlua::Value=table.get(key.as_str())?;} + Ok((next.clone(),table,mlua::Value::Nil)) + })?)?; + table.set_metatable(Some(meta))?; + Ok(table) +} +static SNAPSHOT_DEFAULTS:std::sync::LazyLock=std::sync::LazyLock::new(default_snapshot); + fn query_value(lua: &Lua, value: Value) -> mlua::Result { json_to_lua(lua, &value) } @@ -783,6 +842,7 @@ pub struct Vm { lua: Lua, callbacks: Table, advance: mlua::Function, + timers_due: mlua::Function, budget: Arc, queue: Arc>>, } @@ -800,6 +860,12 @@ impl Vm { LuaOptions::default(), )?; lua.set_memory_limit(16 * 1024 * 1024)?; + // Snapshot closures own native allocations which Lua's heap counter + // cannot see. Keep finalization batches small and advance collection + // after dispatch, instead of retaining hundreds of old native frames. + lua.gc_set_mode(mlua::state::GcMode::Incremental( + mlua::state::GcIncParams::default().step_size(10), + )); for key in [ "pcall", "xpcall", @@ -838,11 +904,19 @@ impl Vm { capabilities.set("scene_transforms", 2)?; capabilities.set("skater", 4)?; capabilities.set("menus", 3)?; - capabilities.set("player_physics", 1)?; - capabilities.set("camera", 2)?; + capabilities.set("player_physics", 2)?; + capabilities.set("engine_access", 1)?; + capabilities.set("command_results", 1)?; + capabilities.set("native_bodies", 1)?; + capabilities.set("input_override", 1)?; + capabilities.set("player_overlap", 1)?; + capabilities.set("landed_details", 1)?; + capabilities.set("camera", 3)?; + capabilities.set("player_control", 1)?; capabilities.set("session", 1)?; capabilities.set("volumes", 1)?; capabilities.set("capture", 1)?; + capabilities.set("multiplayer_debug", 1)?; sdk.set("_native_capabilities", capabilities)?; sdk.set("mod_id", manifest.id.clone())?; sdk.set( @@ -936,6 +1010,8 @@ impl Vm { let sdk: Table = lua.globals().get("sdk")?; let advance = sdk.get("_advance")?; sdk.set("_advance", mlua::Value::Nil)?; + let timers_due = sdk.get("_timers_due")?; + sdk.set("_timers_due", mlua::Value::Nil)?; let code = read_bounded(root, &manifest.entry, 256 * 1024) .map_err(mlua::Error::RuntimeError)?; let source = std::str::from_utf8(&code).map_err(mlua::Error::external)?; @@ -966,6 +1042,7 @@ impl Vm { lua, callbacks, advance, + timers_due, budget, queue, }) @@ -981,28 +1058,58 @@ impl Vm { .set("settings", self.lua.to_value(settings)?) } + #[cfg(test)] pub fn call( &mut self, name: &str, payload: Value, snapshot: &Value, ) -> Result, String> { + self.call_shared(name,payload,&Arc::new(snapshot.clone()),None,&crate::SnapshotFields::default()) + } + + pub fn call_shared(&mut self,name:&str,payload:Value,snapshot:&Arc,physics:Option,fields:&crate::SnapshotFields)->Result,String> { self.budget.store(LUA_BUDGET_UNITS, Ordering::Relaxed); - let invoke = || -> mlua::Result<()> { - self.lua - .globals() - .get::
("sdk")? - .set("snapshot", json_to_lua(&self.lua, &complete(snapshot))?)?; - if let Some(f) = self.callbacks.get::>(name)? { + let invoke = || -> mlua::Result { + let callback=self.callbacks.get::>(name)?; + if callback.is_none() { + if name != "on_update" { return Ok(false); } + let dt = payload["dt"].as_f64().unwrap_or(0.); + // Advance the clock even for mods with no update callback. Only + // a due timer can observe this frame, so otherwise no snapshot + // or native closures need to be allocated at render frequency. + if !self.timers_due.call::(dt)? { + self.advance.call::<()>(dt)?; + return Ok(true); + } + } + let sdk = self.lua.globals().get::
("sdk")?; + let rig_source = snapshot.clone(); + let rig_fields = fields.clone(); + sdk.set("_rig_snapshot", self.lua.create_function(move |lua, fields: Vec| { + let result = lua.create_table_with_capacity(0, fields.len())?; + let rig = rig_fields.get("player_physics").map(|v|v.as_ref()).or_else(||rig_source.get("player_physics")).filter(|v| !v.is_null()) + .unwrap_or(&SNAPSHOT_DEFAULTS["player_physics"]); + for field in fields { + if let Some(value) = rig.get(&field) { + result.raw_set(field, json_to_lua(lua, value)?)?; + } + } + Ok(result) + })?)?; + sdk.set("snapshot", lazy_snapshot_fields(&self.lua,snapshot.clone(),physics,fields.clone())?)?; + if let Some(f) = callback { f.call::<()>(self.lua.to_value(&payload)?)?; } if name == "on_update" { self.advance .call::<()>(payload["dt"].as_f64().unwrap_or(0.))?; } - Ok(()) + Ok(true) }; - let result = invoke(); + let result = invoke().and_then(|ran| { + if ran { self.lua.gc_step().map(|_| ()) } else { Ok(()) } + }); let remaining = self.budget.load(Ordering::Relaxed); let used = LUA_BUDGET_UNITS.saturating_sub(remaining); let approx_instructions = used * LUA_INSTRUCTIONS_PER_BUDGET_UNIT; @@ -1073,6 +1180,8 @@ mod driving_extension_tests { }}) sdk.ui.remove('dash') sdk.camera.clear() + sdk.ui.multiplayer_debug("replication", "Ready") + sdk.ui.multiplayer_debug("replication", "") end } "#).unwrap(); let manifest:Manifest=serde_json::from_value(json!({ @@ -1083,7 +1192,7 @@ mod driving_extension_tests { let snap=json!({"physics":{"bodies":{}}}); let mut vm=Vm::new(&root,&manifest,&BTreeMap::new(),&snap).unwrap(); let out=vm.call("on_load",json!({}),&snap).unwrap(); - assert_eq!(out.len(),5); + assert_eq!(out.len(),7); match &out[0] { Command::CameraRig{options,..}=>{ assert_eq!(options.mode,crate::presentation::CameraMode::Hood); assert!(!options.collision);assert_eq!(options.fov_gain,0.0); @@ -1097,6 +1206,8 @@ mod driving_extension_tests { }, _=>panic!("wrong canvas command") } assert!(matches!(out[3],Command::UiRemove{..})); assert!(matches!(&out[4],Command::CameraFollow{body:None,..})); + assert!(matches!(&out[5],Command::MultiplayerDebug{key,text} if key=="replication" && text=="Ready")); + assert!(matches!(&out[6],Command::MultiplayerDebug{text,..} if text.is_empty())); drop(vm);std::fs::remove_dir_all(root).unwrap(); } } @@ -1415,3 +1526,163 @@ mod graphics_mesh_buffer_tests { .unwrap(); } } + +#[cfg(test)] +mod multiplayer_debug_tests { + use super::Command; + #[test] + fn multiplayer_debug_reports_are_bounded_and_clearable() { + for text in [String::new(), "Ready".into(), "x".repeat(1024)] { + assert!(Command::MultiplayerDebug { key: "replication".into(), text }.validate()); + } + assert!(!Command::MultiplayerDebug { key: "replication".into(), text: "x".repeat(1025) }.validate()); + assert!(!Command::MultiplayerDebug { key: "../bad".into(), text: "Ready".into() }.validate()); + } +} + +#[cfg(test)] +mod snapshot_performance_tests { + use super::*; + #[test] + fn timer_only_mods_observe_fresh_snapshots_and_keep_callback_order() { + let root=std::env::temp_dir().join(format!("skate-timer-snapshot-{}",std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("main.lua"),r#" + return {on_load=function() + sdk.time.after('first',0.02,function() + assert(sdk.snapshot.tick==2 and sdk.time.elapsed==0.02) + sdk.ui.text('timer','first') + sdk.time.after('next',0,function() + assert(sdk.snapshot.tick==3) + sdk.ui.text('timer','next') + end) + end) + end} + "#).unwrap(); + let manifest:Manifest=serde_json::from_value(serde_json::json!({ + "id":"tests.timer-snapshot","api":2,"name":"Timer snapshots","version":"1.0.0", + "author":"test","description":"test","entry":"main.lua","settings":{} + })).unwrap(); + let mut vm=Vm::new(&root,&manifest,&BTreeMap::new(),&Value::Null).unwrap(); + vm.call("on_load",serde_json::json!({}),&Value::Null).unwrap(); + for (tick,expected) in [(1,None),(2,Some("first")),(3,Some("next")),(4,None)] { + let commands=vm.call("on_update",serde_json::json!({"dt":0.01}), + &serde_json::json!({"tick":tick})).unwrap(); + if let Some(expected)=expected { + assert!(matches!(&commands[..], [Command::Overlay{text,..}] if text==expected)); + } else { assert!(commands.is_empty()); } + } + drop(vm); + std::fs::remove_file(root.join("main.lua")).unwrap(); + std::fs::remove_dir(root).unwrap(); + } + #[test] + fn incremental_collection_releases_native_frames_but_preserves_retained_snapshots() { + let root=std::env::temp_dir().join(format!("skate-snapshot-gc-{}",std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("main.lua"),r#" + local first + return {on_fixed_update=function(e) + if not first then first=sdk.snapshot end + if e.tick==1024 then + -- Read a field for the first time after many collections. + assert(first.tick==1 and #first.player_physics.joints==22) + end + end} + "#).unwrap(); + let manifest:Manifest=serde_json::from_value(serde_json::json!({ + "id":"tests.snapshot-gc","api":2,"name":"Snapshot collection","version":"1.0.0", + "author":"test","description":"test","entry":"main.lua","settings":{} + })).unwrap(); + let mut vm=Vm::new(&root,&manifest,&BTreeMap::new(),&Value::Null).unwrap(); + let mut frames=Vec::new(); + for tick in 1..=1024 { + let mut snapshot=fixture(); + Arc::make_mut(&mut snapshot)["tick"]=serde_json::json!(tick); + frames.push(Arc::downgrade(&snapshot)); + vm.call_shared("on_fixed_update",serde_json::json!({"tick":tick}), + &snapshot,None,&crate::SnapshotFields::default()).unwrap(); + } + assert!(frames[0].upgrade().is_some(),"Lua retained the first snapshot"); + assert!(frames[1..512].iter().all(|f| f.upgrade().is_none()), + "discarded native snapshots must be reclaimed during dispatch"); + drop(vm); + assert!(frames.iter().all(|f|f.upgrade().is_none())); + std::fs::remove_file(root.join("main.lua")).unwrap(); + std::fs::remove_dir(root).unwrap(); + } + #[test] + fn shared_native_fields_support_projected_full_and_retained_reads() { + let root=std::env::temp_dir().join(format!("skate-shared-fields-{}",std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("main.lua"),r#" + local old + return {on_update=function() + local current=sdk.rig.read({'tick'}) + assert(current.tick==sdk.snapshot.tick and current.joints==nil) + assert(rawget(sdk.snapshot,'player_physics')==nil) + if old then assert(old.tickArc { + let joints=(0..22).map(|i|serde_json::json!({"index":i,"name":"JOINT_LEFT_ARM","parameters":vec![0;16],"frames":vec![0;20],"load":{"solver_words":vec![0;96],"force":[1,2,3]}})).collect::>(); + Arc::new(serde_json::json!({"tick":1,"player":{"score":123,"position":[1,2,3]},"player_physics":{"joints":joints,"contacts":(0..64).map(|i|serde_json::json!({"id":i,"point":[1,2,3],"force":[1,2,3],"impulse":[1,2,3],"normal":[0,1,0],"a":{"kind":"skater","index":1},"b":{"kind":"world"}})).collect::>()},"engine":{"graphs":{"states":vec![0;1500]}}})) + } + #[test] + fn lazy_fields_are_complete_isolated_and_retained_across_callbacks() { + let lua=Lua::new();let snapshot=fixture(); + let a=lazy_snapshot(&lua,snapshot.clone(),Some(serde_json::json!({"bodies":{"owned":{}}}))).unwrap(); + lua.globals().set("a",a.clone()).unwrap(); + lua.load("assert(rawget(a,'player_physics')==nil); assert(a.player.score==123); a.player.score=5; assert(rawget(a,'player_physics')==nil); assert(a.physics.bodies.owned)").exec().unwrap(); + let b=lazy_snapshot(&lua,snapshot,None).unwrap();lua.globals().set("b",b).unwrap(); + lua.load("assert(b.player.score==123); assert(a.player.score==5); local n=0;for k,v in pairs(a) do n=n+1 end;assert(n>10);assert(#a.player_physics.joints==22);assert(#a.player_physics.joints[1].load.solver_words==96)").exec().unwrap(); + } + #[test] + #[ignore="headless marshaling benchmark; not an FPS claim"] + fn compare_eager_and_demand_driven_snapshot_cost() { + let snapshot=fixture();let lua=Lua::new();let reads:mlua::Function=lua.load("return function(s) return s.player.score end").eval().unwrap(); + let iterations=120;let mods=6; + let begin=std::time::Instant::now(); + for _ in 0..iterations {for _ in 0..mods {for _ in 0..3 { + let full=json_to_lua(&lua,&complete(&snapshot)).unwrap();std::hint::black_box(reads.call::(full).unwrap()); + }}} + let eager=begin.elapsed();lua.gc_collect().unwrap(); + let begin=std::time::Instant::now(); + for _ in 0..iterations {for _ in 0..mods { + // Missing UI callback does no conversion; update/fixed read only player. + for _ in 0..2 {let lazy=lazy_snapshot(&lua,snapshot.clone(),None).unwrap();std::hint::black_box(reads.call::(lazy).unwrap());} + }} + let lazy=begin.elapsed(); + lua.gc_collect().unwrap(); + let rig_reads:mlua::Function=lua.load("return function(s) local rig=s.player_physics; local n=#rig.contacts; for _,j in ipairs(rig.joints) do n=n+j.index end; return n end").eval().unwrap(); + let begin=std::time::Instant::now(); + for _ in 0..iterations {for mod_index in 0..mods { + for callback in 0..2 { + let lazy=lazy_snapshot(&lua,snapshot.clone(),None).unwrap(); + std::hint::black_box(reads.call::(lazy.clone()).unwrap()); + if mod_index==0 && callback==1 {std::hint::black_box(rig_reads.call::(lazy).unwrap());} + } + }} + eprintln!("SNAPSHOT_RIG_BENCH six_mods_one_full_rig_reader_ms_per_frame={:.3}",begin.elapsed().as_secs_f64()*1000./iterations as f64); + eprintln!("SNAPSHOT_BENCH bytes={} mods={mods} iterations={iterations} eager_ms_per_frame={:.3} lazy_ms_per_frame={:.3} ratio={:.1}",serde_json::to_vec(&*snapshot).unwrap().len(),eager.as_secs_f64()*1000./iterations as f64,lazy.as_secs_f64()*1000./iterations as f64,eager.as_secs_f64()/lazy.as_secs_f64()); + } +} diff --git a/crates/skate-mods/tests/physics_api.rs b/crates/skate-mods/tests/physics_api.rs index 7e6e64fa..d09196a9 100644 --- a/crates/skate-mods/tests/physics_api.rs +++ b/crates/skate-mods/tests/physics_api.rs @@ -282,8 +282,8 @@ impl Sim { fn fixed_tick(&mut self, manager: &mut Manager, keys: Value, dt: f32) -> Vec { let physics = self.physics_snapshot(); - manager.snapshot = self.snapshot(physics); - if let Some(obj) = manager.snapshot.as_object_mut() { + manager.snapshot = self.snapshot(physics).into(); + if let Some(obj) = std::sync::Arc::make_mut(&mut manager.snapshot).as_object_mut() { obj.insert("keys".into(), keys); } manager.commands.clear(); @@ -339,7 +339,7 @@ fn manager() -> Manager { "attach": null, "physics": {"bodies": {}, "contacts": [], "touching": []}, "network": null, - }); + }).into(); manager.scan(true); manager } diff --git a/crates/skate-mods/tests/skyline_physics.rs b/crates/skate-mods/tests/skyline_physics.rs index 35464c5c..ef835c33 100644 --- a/crates/skate-mods/tests/skyline_physics.rs +++ b/crates/skate-mods/tests/skyline_physics.rs @@ -349,7 +349,7 @@ impl Sim { "states": {}, "status": "", }, - }); + }).into(); manager.commands.clear(); let mut host = Host { world: &mut self.world, @@ -405,7 +405,7 @@ fn manager() -> Manager { "attach": null, "physics": {"bodies": {}, "contacts": [], "touching": []}, "network": null, - }); + }).into(); manager.scan(true); assert!( manager.packages.get(MOD).is_some_and(|package| package.running()), diff --git a/crates/skate-net/src/lib.rs b/crates/skate-net/src/lib.rs index 761ce2f4..2ad4256e 100644 --- a/crates/skate-net/src/lib.rs +++ b/crates/skate-net/src/lib.rs @@ -8,6 +8,7 @@ pub mod socket; pub mod directory; pub mod packed; pub mod interpolation; +pub mod prediction; pub const MAGIC: &[u8; 8] = b"SK8NET01"; pub const MAX_FRAME: usize = 48_000; diff --git a/crates/skate-net/src/lobby.rs b/crates/skate-net/src/lobby.rs index cb108e71..78155d0d 100644 --- a/crates/skate-net/src/lobby.rs +++ b/crates/skate-net/src/lobby.rs @@ -244,6 +244,13 @@ impl Session { pub fn is_host(&self) -> bool { self.host.is_none() } + /// Authenticated player identity from the host roster, not its transport endpoint. + pub fn host_actor(&self) -> Option { + match self.host { + None => Some(self.local), + Some(peer) => self.links.get(&peer).map(|link| link.actor).filter(|id| *id != 0), + } + } pub fn host_peer(&self) -> u64 { self.host.unwrap_or(self.local) } diff --git a/crates/skate-net/src/packed.rs b/crates/skate-net/src/packed.rs index 4600cda0..0cbdb909 100644 --- a/crates/skate-net/src/packed.rs +++ b/crates/skate-net/src/packed.rs @@ -8,6 +8,8 @@ pub const MAGIC: &[u8; 8] = b"SK8NET05"; pub const HEADER: usize = 29; pub const BODY: u8 = 1; pub const POSE: u8 = 2; +/// Largest finite binary16 rate. Saturate the wire value, never the local simulation. +pub const MAX_RATE: f32 = 65_504.; #[derive(Clone, Debug, PartialEq)] pub struct Packed { /// Source capture time in milliseconds; forwarding never retimestamps it. @@ -149,7 +151,7 @@ fn read_pose(r: &mut Reader, origin: [f32; 3]) -> Option { } fn rates(b: &mut Vec, v: [f32; 3]) { for f in v { - b.extend(f16::from_f32(f).to_bits().to_le_bytes()); + b.extend(f16::from_f32(f.clamp(-MAX_RATE, MAX_RATE)).to_bits().to_le_bytes()); } } fn read_rates(r: &mut Reader, bound: f32) -> Option<[f32; 3]> { @@ -176,11 +178,11 @@ impl Packed { || !body .velocity .iter() - .all(|v| v.is_finite() && v.abs() <= 250.) + .all(|v| v.is_finite()) || !body .angular .iter() - .all(|v| v.is_finite() && v.abs() <= 500.) + .all(|v| v.is_finite()) { return None; } @@ -229,8 +231,8 @@ impl Packed { let mut r = Reader(row); let body = Body { pose: read_pose(&mut r, root.p)?, - velocity: read_rates(&mut r, 250.)?, - angular: read_rates(&mut r, 500.)?, + velocity: read_rates(&mut r, MAX_RATE)?, + angular: read_rates(&mut r, MAX_RATE)?, }; if !r.0.is_empty() { return None; diff --git a/crates/skate-net/src/prediction.rs b/crates/skate-net/src/prediction.rs new file mode 100644 index 00000000..287e36e7 --- /dev/null +++ b/crates/skate-net/src/prediction.rs @@ -0,0 +1,88 @@ +//! Short-lived collision prediction in local arrival time. Never subtract peer +//! capture timestamps from a local clock: the clocks need not be synchronized. +use crate::{Body, Pose}; + +/// Body snapshots normally arrive every 50 ms. Coast for one interval, then +/// smoothly stop over two more intervals. Expire instead of leaving an invisible +/// moving obstacle behind when a peer stalls. Rendering has its own buffer. +#[derive(Clone, Copy, Debug)] +pub struct CollisionPrediction { + travel: f32, + speed: f32, +} +impl CollisionPrediction { + pub fn at(age: f32) -> Option { + const COAST: f32 = 0.05; + const EXPIRE: f32 = 0.15; + if !age.is_finite() || age < 0. || age >= EXPIRE { + return None; + } + let fading = (age - COAST).max(0.); + let fade_duration = EXPIRE - COAST; + Some(Self { + // Integral of the velocity scale, so contact velocity matches the + // motion of the predicted collider even during packet loss. + travel: age - fading * fading / (2. * fade_duration), + speed: 1. - fading / fade_duration, + }) + } + + /// Input bodies have already passed the wire protocol's finite-value bounds. + pub fn body(self, body: &Body) -> Body { + let [wx, wy, wz] = body.angular; + let magnitude = (wx * wx + wy * wy + wz * wz).sqrt(); + let mut q = body.pose.q; + if magnitude > 0. { + let half_angle = 0.5 * magnitude * self.travel; + let scale = half_angle.sin() / magnitude; + let [x, y, z] = [wx * scale, wy * scale, wz * scale]; + let w = half_angle.cos(); + let [qx, qy, qz, qw] = q; + // World-space angular velocity: delta rotation multiplies on left. + q = [ + w * qx + x * qw + y * qz - z * qy, + w * qy - x * qz + y * qw + z * qx, + w * qz + x * qy - y * qx + z * qw, + w * qw - x * qx - y * qy - z * qz, + ]; + } + let length = q.iter().map(|v| v * v).sum::().sqrt(); + Body { + pose: Pose { + p: std::array::from_fn(|i| body.pose.p[i] + body.velocity[i] * self.travel), + q: q.map(|v| v / length), + }, + velocity: body.velocity.map(|v| v * self.speed), + angular: body.angular.map(|v| v * self.speed), + } + } +} + +/// Distinguish a relocation from legitimate high-speed travel using source time, +/// not arrival jitter. Ten metres is slack for root animation and an impact; +/// published body speed supplies the additional distance possible between samples. +pub fn is_discontinuity( + previous: &crate::packed::BodyState, + current: &crate::packed::BodyState, + elapsed: f32, +) -> bool { + let dt = if elapsed.is_finite() { + elapsed.max(0.) + } else { + 0. + }; + let speed = previous + .bodies + .iter() + .chain(¤t.bodies) + .map(|b| b.velocity.iter().map(|v| v * v).sum::().sqrt()) + .fold(0., f32::max); + let distance_squared = previous + .root + .p + .iter() + .zip(current.root.p) + .map(|(a, b)| (a - b).powi(2)) + .sum::(); + distance_squared > (10. + speed * dt * 1.25).powi(2) +} diff --git a/crates/skate-net/tests/application.rs b/crates/skate-net/tests/application.rs index 2e32dafa..b8ff9894 100644 --- a/crates/skate-net/tests/application.rs +++ b/crates/skate-net/tests/application.rs @@ -143,3 +143,19 @@ fn application_snapshot_bursts_without_loopback() { } assert_eq!(h.actors[&2].application.len(), 64); } + +#[test] +fn host_player_identity_is_resolved_from_roster_not_transport_endpoint() { + let mut host = Session::new(7, info(99), None); + let mut guests = vec![Session::new(7, info(2), Some(1))]; + assert_eq!(host.host_actor(), Some(99)); + assert_eq!(guests[0].host_actor(), None); + host.publish_application("net:match:state", b"host turn".to_vec(), 0); + for t in (0..2000).step_by(50) { pump(&mut host, &mut guests, t, true); } + let guest = &mut guests[0]; + assert_eq!(guest.host_peer(), 1); + assert_eq!(guest.host_actor(), Some(99)); + assert_eq!(guest.actors[&guest.host_actor().unwrap()].application["net:match:state"].value, b"host turn"); + guest.migrate(Some(5), 2000); + assert_eq!(guest.host_actor(), None); +} diff --git a/crates/skate-net/tests/high_speed.rs b/crates/skate-net/tests/high_speed.rs new file mode 100644 index 00000000..6aa3a6f9 --- /dev/null +++ b/crates/skate-net/tests/high_speed.rs @@ -0,0 +1,79 @@ +use skate_net::{ + Body, Pose, + packed::{self, BodyState, MAX_RATE, Packed}, + prediction::is_discontinuity, +}; +fn state(x: f32, speed: f32) -> BodyState { + let pose = Pose { + p: [x, 0., 0.], + q: [0., 0., 0., 1.], + }; + BodyState { + root: pose, + enabled: (1u64 << 33) - 1, + bodies: vec![ + Body { + pose, + velocity: [speed, 0., 0.], + angular: [0.; 3] + }; + 33 + ], + } +} +#[test] +fn backwards_man_speed_keeps_full_and_delta_snapshots_flowing() { + let mut baseline = None; + for (seq, speed) in [200., 251., 900., 2400., 80_000., 100.] + .into_iter() + .enumerate() + { + let mut source = state(seq as f32 * 50., speed); + source.bodies[19].angular = [900., -1000., 0.]; + let packed = Packed::body(&source).expect("one fast body must not drop the player"); + let wire = packed::delta( + 1, + 2, + packed::BODY, + seq as u32 + 1, + &packed, + baseline.as_ref().map(|(s, p)| (*s, p)), + ); + assert!(wire.len() <= packed::MTU); + let decoded = packed::apply(&wire, baseline.as_ref().map(|(_, p)| p)).unwrap(); + let result = decoded.unpack_body().unwrap(); + assert_eq!(result.root.p, source.root.p); + assert_eq!(result.bodies[0].velocity[0], speed.min(MAX_RATE)); + assert_eq!(result.bodies[19].angular[0], 900.); + assert_eq!(source.bodies[0].velocity[0], speed); + baseline = Some((seq as u32 + 1, decoded)); + } +} +#[test] +fn nonfinite_rates_are_still_rejected_on_send_and_receive() { + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut source = state(0., 10.); + source.bodies[5].velocity[1] = value; + assert!(Packed::body(&source).is_none()); + } + let mut packed = Packed::body(&state(0., 10.)).unwrap(); + // Compact body pose occupies eleven bytes, then binary16 velocity starts. + packed.rows[0][11..13].copy_from_slice(&0x7c00u16.to_le_bytes()); + assert!(packed.unpack_body().is_none()); + let wire = packed::delta(1, 2, packed::BODY, 1, &packed, None); + assert!(packed::apply(&wire, None).is_none()); +} +#[test] +fn high_speed_motion_and_lost_samples_do_not_reset_playback() { + for speed in [250., 900., 2400.] { + for dt in [0.05, 0.1, 0.3] { + assert!(!is_discontinuity( + &state(0., speed), + &state(speed * dt, speed), + dt + )); + } + } + assert!(is_discontinuity(&state(0., 0.), &state(100., 0.), 0.05)); + assert!(is_discontinuity(&state(0., 20.), &state(100., 20.), 0.05)); +} diff --git a/crates/skate-net/tests/prediction.rs b/crates/skate-net/tests/prediction.rs new file mode 100644 index 00000000..5f92dae1 --- /dev/null +++ b/crates/skate-net/tests/prediction.rs @@ -0,0 +1,88 @@ +use skate_net::{Body, Pose, prediction::CollisionPrediction}; + +fn body() -> Body { + Body { + pose: Pose { + p: [1., 2., 3.], + q: [0., 0., 0., 1.], + }, + velocity: [12., -2., 4.], + angular: [0., 2., 0.], + } +} +fn predicted(age: f32) -> Body { + CollisionPrediction::at(age).unwrap().body(&body()) +} +fn near(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 0.002, "{actual} != {expected}"); +} + +#[test] +fn fresh_snapshot_coasts_and_rotates_at_published_velocity() { + let b = predicted(0.025); + near(b.pose.p[0], 1.3); + near(b.pose.p[1], 1.95); + near(b.pose.q[1], 0.025_f32.sin()); + near(b.pose.q[3], 0.025_f32.cos()); + assert_eq!(b.velocity, body().velocity); + assert_eq!(b.angular, body().angular); +} + +#[test] +fn contact_velocity_matches_collider_motion_through_packet_loss() { + // Includes both sides of the coasting/fading boundary and two lost packets. + for age in [0.01, 0.049, 0.05, 0.051, 0.075, 0.1, 0.125, 0.149] { + let dt = 0.0005; + let before = predicted(age - dt); + let after = predicted(age + dt); + let current = predicted(age); + for i in 0..3 { + // A centered difference across the fade boundary averages its two accelerations. + let measured = (after.pose.p[i] - before.pose.p[i]) / (2. * dt); + assert!((measured - current.velocity[i]).abs() < 0.02); + } + let angle = |b: &Body| 2. * b.pose.q[1].atan2(b.pose.q[3]); + assert!(((angle(&after) - angle(&before)) / (2. * dt) - current.angular[1]).abs() < 0.005); + } +} + +#[test] +fn prolonged_loss_expires_instead_of_leaving_a_moving_ghost() { + let last = predicted(0.14999); + near(last.pose.p[0], 2.2); // Bounded to 100 ms of full-speed travel. + near(last.velocity[0], 0.); + near(last.angular[1], 0.); + for age in [0.15, 0.5, 10., f32::INFINITY, f32::NAN, -0.01] { + assert!(CollisionPrediction::at(age).is_none()); + } +} + +#[test] +fn resumed_or_teleported_snapshot_starts_from_its_own_state() { + assert!(CollisionPrediction::at(2.).is_none()); + let mut resumed = body(); + resumed.pose.p = [100., 4., -200.]; + resumed.velocity = [0.; 3]; + resumed.angular = [0.; 3]; + let b = CollisionPrediction::at(0.).unwrap().body(&resumed); + assert_eq!(b.pose, resumed.pose); + assert_eq!(b.velocity, resumed.velocity); +} + +#[test] +fn angular_prediction_uses_world_axes_and_normalizes_orientation() { + let mut b = body(); + // Start rotated 90 degrees about X, then rotate about world Y. + let s = std::f32::consts::FRAC_1_SQRT_2; + b.pose.q = [s, 0., 0., s]; + let result = CollisionPrediction::at(0.05).unwrap().body(&b); + near(result.pose.q[0], s * 0.05_f32.cos()); + near(result.pose.q[1], s * 0.05_f32.sin()); + near(result.pose.q[2], -s * 0.05_f32.sin()); + near(result.pose.q[3], s * 0.05_f32.cos()); + assert!(result.pose.valid()); + b.angular = [500., -500., 500.]; + for age in [0., 0.05, 0.1, 0.149] { + assert!(CollisionPrediction::at(age).unwrap().body(&b).pose.valid()); + } +} diff --git a/docs/physics/grind-trick-state-202.md b/docs/physics/grind-trick-state-202.md new file mode 100644 index 00000000..84645ed0 --- /dev/null +++ b/docs/physics/grind-trick-state-202.md @@ -0,0 +1,25 @@ +# State 202: grind trick exit + +Crash report revision84142e78, tick64957: GrindDarkslide405 requested PhysicsAirSecondary202 with Processed2484 bit0x100000 set. The registry rejected it before native lifecycle publication; the first failure is a missing adapter, not a non-finite solver result. + +## Original Skate 3 evidence + +Read from the open TU3 IDA database default.patched.xex (SHA256431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a). + +- PhysicalPlayer constructor82DB27AC allocates80bytes, installs vtable82327174 at82DB2808 and stores the owner at+1716 at82DB2824. +- GetType leaf82D33160 returns202 (li r3,0xCA). GetTypeString82D33168 returns82326F54, the string PHYSICS_STATE_GRIND_TRICK. PhysicsAirSecondary is the existing host enum name, not a second instance of the ordinary PhysicsAir200 class. +- Vtable slots: destructor82D33598, empty reset82B61BB8, Update82D336F8, empty FillPhysOut82B61BB8, Enter82D335F8, Exit82D33678, PredictFutureOfDeck82D34DA8, GetType82D33160, GetTypeString82D33168, empty slot36, PostPhysics82D33A70. +- Enter resets velocity48, counter64 and latch68; sets Skeleton16505; enables angular-only board animation (82C05658); selects wipeout mode1 and clears its balance only on a mode change. +- Update resets counter64 while Processed2468 bit0x4000 is set, otherwise increments it. After more than3 free updates latch68 stays set until Enter. +- It transforms Skeleton12048 trajectory translation times60 through Skeleton11920 animation-to-world axes into velocity48. The trajectory rotation also transforms the retained Reckoning1200 heading. Processed528 seeds Reckoning1136, then82D8E5C0/82D8C8F0 compute the ground-style reckoning. Skeleton82BDDA10 uses fast blending; Skeleton16388 is armed. +- Board update82D33928 zeros steering, selects active-mode wheel material, computes linear drag via82D94A20, and applies the normal collision response82D944E8 as force15 if present. Otherwise it sets the calculated drag. +- Post82D33A70 calls ground-animation wipeout checks82D8F918 with scale2 before release and0.5 after. Unless Processed2472 bit0x8000 is set, it projects the deck velocity onto normalized authored motion and blends that component toward authored motion with native constants0.9 and1.4 (82098F70/74); perpendicular velocity is preserved. +- Exit clears drag and restores standard wheel materials. No ordinary-air trajectory prediction/following is substituted. + +## Host integration + +A dedicated GrindTrick owner implements these methods. State202 is accepted by transition, pre-state and output registries, dispatched during the state update and post-physics phases, and retains the existing native selector's return to200 when the grind-trick flag clears. Existing darkslide Exit runs before202 Enter. + +Regression coverage includes the reported405->202 transition,202 exits, four-frame sticky latch behavior, and authored-velocity decomposition. A stock-asset headless test executes405 Exit,202 Enter/Update/Predict/Fill/Post and202 Exit into200 without creating a game window. + +Validation: cargo check succeeded; three focused tests passed; the ignored stock-asset lifecycle test was explicitly run with SKATE3_ASSET_ROOT=assets and passed. The exact gameplay sequence on the reported rail was not replayed. diff --git a/docs/physics/quarter-pipe-transition-input.md b/docs/physics/quarter-pipe-transition-input.md new file mode 100644 index 00000000..be46fcdc --- /dev/null +++ b/docs/physics/quarter-pipe-transition-input.md @@ -0,0 +1,29 @@ +# Quarter-pipe transition input + +Verified against both open IDA databases on 2026-09-13. + +## Skate 3 TU3 evidence + +Database: default.patched.xex, SHA256 431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. + +- PlayerInput phase 0x82DB4330 calls 0x825903C8 when actor input is available and animation suppression is clear. +- At 0x82590414, that helper loads the expression index from the action table at +0x104 (65 * 4), then evaluates it via 0x8296C350 at 0x82590420. +- The caller clamps the result to [-1, 1] at 0x82DB4334..434C and stores it into ProcessedPhysIn+0xA4C (2636) at 0x82DB4354. +- Vert adjustment 0x82D67D08 reads this field at 0x82D67F5C. The following arithmetic subtracts 0.25 and clamps to [-1, 1]. +- At 0x82D68094 it forms world-up minus ground-normal times the clamped lean times VertJumpAlignMaxAngle. The steep-surface/upward-motion gates and alignment blend then determine the starting velocity; original speed is restored after direction normalization. + +## Skate 2 corroboration + +Database: sk82_na_m.xex, SHA256 3706cedabd24fcd3dd16c99405ff5725deab5b09739ae926324db88a0b7c0fe2. + +0x82DBFBC0 is named Sk8::Physics::TrajectorySelector::AdjustStartingVelOnVerts. At 0x82DBFE10..FE1C the decompiler identifies the source as mProcessedPhysIn->mTransitionInput and shows the same subtract-0.25/clamp operation. Skate 2 corroborates the field and behavior; Skate 3 assembly determines the actual binding. + +## Adapter defect and correction + +The host previously supplied actions.value(71), the right trigger. GameplayActions maps action 65 to pad[18] - pad[19], the signed left-stick Y axis; action 71 maps to pad[11], the right trigger. Xbox conversion makes pushing the left stick forward positive. + +Consequently, forward stick with no trigger supplied zero transition input (lean -0.25), whereas full forward should supply approximately +1 (lean +0.75). This reverses the requested lean relative to the inward ramp normal and explains the reported return into the quarter pipe. The host now supplies action 65, matching the original helper. No trajectory settings or forces were retuned. + +## Validation and limits + +Two headless regression tests pass: raw controller forward/backward/neutral/partial/deadzone input reaches the production adapter, and neither grab trigger nor right-stick trick input changes it. The dev executable compiled successfully. Gameplay was not launched or visually verified. Staging to bin/skate3rust.exe was blocked because that executable was in use. diff --git a/mods/physics-sandbox/main.lua b/mods/.disabled-physics-sandbox/main.lua similarity index 100% rename from mods/physics-sandbox/main.lua rename to mods/.disabled-physics-sandbox/main.lua diff --git a/mods/physics-sandbox/mod.json b/mods/.disabled-physics-sandbox/mod.json similarity index 100% rename from mods/physics-sandbox/mod.json rename to mods/.disabled-physics-sandbox/mod.json diff --git a/mods/Skyline_Drive_Mod/README.md b/mods/Skyline_Drive_Mod/README.md index b326f3f0..d096d383 100644 --- a/mods/Skyline_Drive_Mod/README.md +++ b/mods/Skyline_Drive_Mod/README.md @@ -75,3 +75,6 @@ includes Rust regression tests and `validate_model`, which runs the real cook, Rapier spawn and compact replication round trip on your machine without producing collision files. Use J and test roof standing/walking and vehicle impacts after successful compilation; no unconditional no-penetration claim is made. + +## 4.4.0 API migration +Requires the generalized engine API build. Chassis creation uses command results and waits for successful body creation before graphics and effects are bound. Failed creation reports the host error and can be retried with F10. Driving parameters are unchanged. diff --git a/mods/Skyline_Drive_Mod/SHA256SUMS.txt b/mods/Skyline_Drive_Mod/SHA256SUMS.txt index ba72d2be..26b408de 100644 --- a/mods/Skyline_Drive_Mod/SHA256SUMS.txt +++ b/mods/Skyline_Drive_Mod/SHA256SUMS.txt @@ -1,13 +1,16 @@ -1b1f197c6fd136e1725bdaf82cbe423acdd783f809e6c269c13cd0bc1a805e8d README.md -4dcc30662bc52ce46a5728db8f53405bb3ef17201123c0fc8df902c9d2d4f6f7 audio/brake.wav +4dcc30662bc52ce46a5728db8f53405bb3ef17201123c0fc8df902c9d2d4f6f7 audio/_brake_source.wav +68070ec845c842759f84f64b32623b3a508b526defefac475c741555987d8e9b audio/brake.wav +0c98d67226b170d6132f55bfccb775f076955bacabe7b5e0f52b3fedbd0d8378 audio/build_drift_loop.py f03d5351db25a5653e3b218fac60c8131ebacf29085bc0cc41e9e9e853267653 audio/engine.wav 383a32fa81e609dfed2a18ca19f12e0901f611374e054602680763e652530db0 audio/lift.wav a11335deddf0ac82e67781cc46a688974fa4e6955e966a839b69aeb41c009061 audio/pop.wav -a8583676cc6510f61bbd226ad39f5dda3cfe3685454a56ff281af1283feeb508 audio/soundbank.json +01d5cde7fbf83bee22bfd17e53cc01e29c0c295c85aec66ca3d8a51899c5c21e audio/soundbank.json +66c5bbc42632641a211b2cec02fce7669e4623e4469598744509e526b2b13edc audio/synthesize.py 4ab8b076191b5c226bb8261684a8c7be10260e1012bf80b2f81f5fb08bc92fe1 audio/turbo_loop.wav 79e8b4cb1de4f2b9fad682d42b133fee5abc957de1afa27154ac9ac62b25dc24 fallback_car.glb -98dc33fc1deee7f2e697b3301f710ea3c0d226f7cd97c87fe79265afacbd63cc main.lua -4c8f844649f90c13c9da21db129a53f55d17cbffefb0f169989b2d659c991645 mod.json +408da5b6915244a1c3885b31655311485e59d0d79fb9c5009a8f3400d6278395 main.lua +49773c208ec65ffe69e577ce7fd6e0c22cb01aca3910d5fa9d657533c42e8466 mod.json +3be337826f20b9002617d96c1b42ad0c9527948d940d817341b747d6834f8843 README.md 0596747c6ced1b93ce94e02618c4e0122799c6b2f7e63eae76b895b4d0241713 skyline.glb 0e57bc34b1e21a698ba51cdd9b1b591688af3264dd57ecdd6e2c2b3184fdbe30 textures/skid_tread.png eea004bbe843d4c0c806df71914dcc7f6ea5835052a55213b8bf78b89c68e40f textures/smoke_soft.png diff --git a/mods/Skyline_Drive_Mod/main.lua b/mods/Skyline_Drive_Mod/main.lua index 7794b0f5..758e77ef 100644 --- a/mods/Skyline_Drive_Mod/main.lua +++ b/mods/Skyline_Drive_Mod/main.lua @@ -155,7 +155,7 @@ local function prepare_audio() sound.supported=type(sdk.audio)=="table" and number(sdk.audio.version,0)>=1 if not sound.supported then sdk.log("Skyline audio unavailable: install the native Audio API update and rebuild the game.") - sdk.ui.text("skyline_audio","Sound requires native Audio API extension 1. Physics still works.") + sdk.log("Skyline: sound requires Audio API extension 1.") return end if sound.prepared then return end @@ -392,34 +392,21 @@ local REMOTE_WHEEL_KEYS={rr="wheel_rr",rl="wheel_rl"} local function vfx_net_active() return sdk.net and type(sdk.net.info)=="function" and (sdk.net.info().active==true) end -local function refresh_mp_status(dt) - if not sdk.net or type(sdk.net.info)~="function" then - sdk.ui.text("skyline_mp","") - return - end - local info=sdk.net.info() - if not info or info.active~=true then - sdk.ui.text("skyline_mp","") - return - end - state.mp_hint_time=math.max(0,(state.mp_hint_time or 0)-dt) - if state.mp_hint_time>0 then return end - state.mp_hint_time=0.5 +local multiplayer_debug_timer=0 +local function refresh_multiplayer_debug(dt) + if not sdk.capabilities or (sdk.capabilities.multiplayer_debug or 0)<1 then return end + multiplayer_debug_timer=multiplayer_debug_timer-dt + if multiplayer_debug_timer>0 then return end + multiplayer_debug_timer=0.5 + local active=vfx_net_active() local lines={ - state.spawned - and "Your car is spawned and should replicate to other players." - or "Press F10 to spawn your car. Other players cannot see your vehicle until you do.", - "Both players need Skyline enabled with identical mod files and the skyline-driving-update build.", + active and "Multiplayer active" or "Multiplayer offline", + state.spawned and "Local car: spawned" or "Local car: not spawned (F10)", + state.occupied and "Driver: in car" or "Driver: on foot", + vfx.supported and (vfx.mesh and "VFX: buffers ready" or "VFX: buffers pending") or "VFX: unsupported", + "Vehicle replication requires matching enabled Skyline packages on each player.", } - if vfx.supported then - lines[#lines+1]=vfx.mesh - and "VFX buffers ready (remote smoke/skids work without spawning your own car)." - or "VFX supported but buffers not ready yet." - else - lines[#lines+1]="VFX unsupported: update to a build with mesh_buffer (graphics API 4)." - end - if info.status and info.status~="" then lines[#lines+1]=info.status end - sdk.ui.text("skyline_mp",table.concat(lines,"\n")) + sdk.ui.multiplayer_debug("replication",table.concat(lines,"\n")) end local function clear_mesh(mesh) mesh.positions={};mesh.normals={};mesh.colors={};mesh.uvs={};mesh.indices={} @@ -1224,7 +1211,7 @@ local function prepare_presentation() set_debug_visible(sdk.settings.show_driving_debug==true) if not presentation.supported then sdk.log("Skyline: camera/canvas extension missing. Rebuild with the supplied Driving API source patch.") - sdk.ui.text("skyline_presentation","Dynamic camera + speedometer require the Driving API rebuild.") + sdk.ui.text("skyline_presentation", "") else sdk.ui.text("skyline_presentation","") end end local function update_dashboard(event) @@ -1267,9 +1254,9 @@ local function update_dashboard(event) rect("rpm_bg",16,144,312,9,{0.13,0.18,0.23,1}) rect("rpm_fill",16,144,312*amount,9,accent) rect("redline_tick",16+312*0.94,141,2,15,{1,0.31,0.20,1}) - text("hint",16,161,312,16,"X / C CAMERA H DEBUG J HULL",10,muted) + text("hint",16,161,312,16,"X / C CAMERA",10,muted) sdk.ui.canvas("driving_dashboard",{ - anchor="bottom_right",offset={24,28},size={344,180}, + anchor="bottom_left",offset={24,28},size={344,180}, scale=clamp(number(sdk.settings.dashboard_scale,1),0.65,1.5),visible=true,items=items, }) presentation.active_hud=true @@ -1333,7 +1320,7 @@ local function spawn_rig(request) if not y then sdk.log("Skyline: spawn needs map ground beneath all four wheels"); return end height=math.max(height,y+C.radius-off[2]+0.025) end - sdk.physics.spawn(BODY,{ + sdk.commands.request("spawn_chassis",{kind="physics_spawn",key=BODY,body={ shape={type="model",path=MODEL,object="skyline_mesh", options={max_hulls=32,resolution=96,concavity=0.0025}}, body_type="dynamic",mass=C.mass,position={position[1],height,position[3]}, @@ -1342,19 +1329,20 @@ local function spawn_rig(request) contact_group=8, center_of_mass=C.center,inertia_half_extents=C.inertia_half, linear_damping=0,angular_damping=0, - }) - sdk.graphics.mesh("skyline_visual",{path=MODEL,body=BODY}) - prepare_effects() + }}) reset_simulation() state.spawn_confirm={ - deadline=state.time+0.15, reenter=request.reenter==true, } end +local interaction_text local function confirm_spawn() local pending=state.spawn_confirm if not pending then return end - if sdk.physics.read(BODY) then + local result=sdk.commands.result("spawn_chassis") + if result and result.ok and sdk.bodies.read({kind="mod",key=BODY}) then + sdk.graphics.mesh("skyline_visual",{path=MODEL,body=BODY}) + prepare_effects() state.spawn_confirm=nil state.spawned=true state.enter_after=math.max(state.enter_after,state.time+0.25) @@ -1362,10 +1350,10 @@ local function confirm_spawn() state.reenter_on_ready=pending.reenter==true return end - if state.time Broken bones > Heal all injuries** to restore this mod's +overrides. Turning off Enable injuries also heals. Map changes/unload restore +controls. Heal on teleport is enabled by default. Normal bail recovery alone +does not heal injuries. + +Settings expose impulse threshold (default 80 N s), minimum closing speed +(3 m/s), cooldown, bail-only detection and HUD visibility. Thresholds are gameplay +settings, not medical fracture estimates. Resting support force alone cannot +cause an injury. Joint names and indices come from the loaded native rig. + +All injury logic is in `main.lua`. The host only exposes body/contact observations, +joint and drive controls, menu actions, networking and command results. A failed +override is reported instead of falsely marking a joint injured. Owned overrides +are restored without clearing another mod's direct joint settings. + +With the mod enabled on peers, the HUD displays remote injury counts; native +physical poses use the existing multiplayer pose stream. There are no detached +limb meshes or blood effects. Headless Lua/native integration tests cover the +rules and constraint controls; injury feel still needs gameplay evaluation. diff --git a/mods/broken-bones/SHA256SUMS.txt b/mods/broken-bones/SHA256SUMS.txt new file mode 100644 index 00000000..4ede2391 --- /dev/null +++ b/mods/broken-bones/SHA256SUMS.txt @@ -0,0 +1,3 @@ +5d30691ecb4b3865c462680685a3c146fe7fb6e8fd0b41d5e951fb93fe6a20ae main.lua +1461c828f1fad08bb6f7f96ddde56368af499caca6deb7a709d3ad34bdcf8378 mod.json +6b2ae2e24202b97ca88173cca3412e0c6ba1245fbf2896067e98bbdf2d523599 README.md diff --git a/mods/broken-bones/main.lua b/mods/broken-bones/main.lua new file mode 100644 index 00000000..2d0ddd28 --- /dev/null +++ b/mods/broken-bones/main.lua @@ -0,0 +1,94 @@ +-- Injury decisions belong to Lua; the engine exposes generic bodies/constraints. +local broken, pending, last_hit, recent = {}, {}, {}, {} +local last_tick, last_mode, was_enabled, status = nil, nil, nil, "" +local function cfg(k,d) local v=sdk.settings[k];if v==nil then return d end;return v end +local function length(v) v=v or {};return math.sqrt((v[1] or 0)^2+(v[2] or 0)^2+(v[3] or 0)^2) end +local function publish() + local ids={};for i in pairs(broken) do ids[#ids+1]=i end;table.sort(ids) + sdk.net.publish("injuries",{v=1,j=ids}) +end +local function heal() + for i in pairs(broken) do sdk.rig.reset_joint(i) end + for i in pairs(pending) do sdk.rig.reset_joint(i) end + broken,pending,recent,last_hit={},{},{},{};status="Joints restored";publish() +end +local function menu() + sdk.ui.menu("bones",{title="Broken bones",section="Skater mods",items={ + {id="heal",label="Heal all injuries",description="Restore this mod's joint and drive overrides."}, + }}) +end +local function eligible(j) + return j and (j.name:find("LEFT",1,true) or j.name:find("RIGHT",1,true)) +end +local function draw() + if not cfg("show_hud",true) then sdk.ui.text("bones","");return end + local labels={};for _,j in pairs(broken) do labels[#labels+1]=j.name:gsub("JOINT_",""):gsub("_"," ") end;table.sort(labels) + local text="BROKEN BONES | "..(#labels==0 and "No injuries" or table.concat(labels,", ")) + for _,id in ipairs(sdk.net.players()) do + if tostring(id)~=tostring(sdk.net.info().local_id) then + local p=sdk.net.read(id,"injuries") + if type(p)=="table" and p.v==1 and type(p.j)=="table" and #p.j>0 then + local skater=sdk.player.skater(id);text=text.."\n"..(skater and skater.name or tostring(id))..": "..#p.j.." injured joints" + end + end + end + if status~="" then text=text.."\n"..status end + sdk.ui.text("bones",text:sub(1,1000)) +end +local function fixed() + local rig=sdk.rig.read({"tick","ragdoll","contacts"});local player=sdk.player.read();local now=sdk.time.elapsed + if cfg("heal_on_teleport",true) and player.mode=="teleport" and last_mode~="teleport" then heal() end + last_mode=player.mode + local enabled=cfg("enabled",true) + if was_enabled and not enabled then heal() end;was_enabled=enabled + for i,j in pairs(pending) do + local result=sdk.commands.result("joint_"..i) + if result then + pending[i]=nil + if result.ok then broken[i]=j;status="Impact injured "..j.name:gsub("JOINT_",""):gsub("_"," ");publish() + else status="Joint unchanged: "..tostring(result.error) end + end + end + if not enabled or last_tick==rig.tick or sdk.player.attached() or player.suspended then draw();return end + last_tick=rig.tick + local joints + local strongest={} + for _,c in ipairs(rig.contacts or {}) do + if c.phase~="end" and (c.closing_speed or 0)>=cfg("closing_speed",3) then + if not joints then + joints={};for _,j in ipairs(sdk.rig.joints()) do joints[j.child]=j end + end + for _,body in ipairs({c.a,c.b}) do + if body and body.kind=="skater" then + local j=joints[body.index];local impulse=length(c.impulse) + if eligible(j) and impulse>=cfg("impact_impulse",80) and not broken[j.index] and not pending[j.index] and + (not j.override_owner or j.override_owner==sdk.mod_id) and now-(last_hit[j.index] or -100)>=cfg("cooldown",0.75) then + if not strongest[j.index] or impulse>strongest[j.index].impulse then strongest[j.index]={joint=j,impulse=impulse,at=now} end + end + end + end + end + end + for i,hit in pairs(strongest) do recent[i]=hit end + for i,hit in pairs(recent) do + if now-hit.at>0.3 then recent[i]=nil + elseif not cfg("only_bails",false) or player.bailing or rig.ragdoll then + sdk.commands.request("joint_"..i,{kind="player_joint",joint=i,options={free_swing=true,free_twist=true, + drive_enabled=false,possession_enabled=false,descendants=true}}) + pending[i]=hit.joint;last_hit[i]=now;recent[i]=nil + end + end + draw() +end +return { + on_load=function() + if (sdk.capabilities.player_physics or 0)<2 or not sdk.capabilities.command_results then error("Broken Bones requires the generalized native rig API build") end + menu();publish();draw() + end, + on_fixed_update=fixed, + on_event=function(e) + if e.name=="world_changed" then broken,pending,recent,last_hit={},{},{},{};last_tick=nil;status="";publish();menu() + elseif e.name=="menu_action" and e.menu=="bones" and e.item=="heal" then heal();draw() end + end, + on_unload=function() heal();sdk.net.publish("injuries",nil);sdk.ui.remove_menu("bones");sdk.ui.text("bones","") end, +} diff --git a/mods/broken-bones/mod.json b/mods/broken-bones/mod.json new file mode 100644 index 00000000..2aa7c4b9 --- /dev/null +++ b/mods/broken-bones/mod.json @@ -0,0 +1,62 @@ +{ + "id": "examples.broken-bones", + "name": "Broken Bones", + "version": "1.0.0", + "api": 2, + "author": "Skate3clone", + "description": "Impact-based limb injuries using native contacts, joint topology and reversible constraint overrides. All damage rules run in Lua.", + "entry": "main.lua", + "settings": { + "impact_impulse": { + "type": "number", + "label": "Impact impulse threshold", + "description": "Gameplay threshold in N s; tune for your preferred injury frequency.", + "default": 80, + "min": 5, + "max": 1000, + "step": 5 + }, + "closing_speed": { + "type": "number", + "label": "Minimum impact speed", + "description": "Minimum pre-solve closing speed in metres/second; excludes resting support.", + "default": 3, + "min": 0.5, + "max": 20, + "step": 0.5 + }, + "cooldown": { + "type": "number", + "label": "Impact cooldown", + "description": "Seconds between counted impacts on the same joint.", + "default": 0.75, + "min": 0.1, + "max": 5, + "step": 0.05 + }, + "enabled": { + "type": "boolean", + "label": "Enable injuries", + "description": "Process native impacts while enabled.", + "default": true + }, + "only_bails": { + "type": "boolean", + "label": "Only injure during bails", + "description": "Retain recent impacts briefly to catch the transition into a bail.", + "default": false + }, + "show_hud": { + "type": "boolean", + "label": "Show injuries", + "description": "Display local and remote injury summaries.", + "default": true + }, + "heal_on_teleport": { + "type": "boolean", + "label": "Heal on teleport", + "description": "Restore joints when a teleport/reset state is observed.", + "default": true + } + } +} diff --git a/mods/game-of-skate/main.lua b/mods/game-of-skate/main.lua index 25be2e6f..3833f2b4 100644 --- a/mods/game-of-skate/main.lua +++ b/mods/game-of-skate/main.lua @@ -1,233 +1,224 @@ --- Game rules belong in Lua. The engine only publishes generic scoring events. +-- All match rules and turn authority live here; the SDK is game-mode agnostic. local WORD = "SKATE" -local seen, game = {}, nil -local last_publish = -1 -local running = false -local menu_stamp - +local game, running, serial = nil, false, 0 +local received, received_host +local applied, released, ack, restore = nil, nil, nil, nil +local armed_at, last_publish, menu_stamp = 0, -1, nil local function info() return sdk.net.info() end local function me() return tostring(info().local_id or "0") end +local function host() return not info().active or info().is_host end local function roster() - local ids, have = {}, {} - for _, id in ipairs(sdk.net.players()) do - id = tostring(id) - if not have[id] then ids[#ids + 1], have[id] = id, true end + local ids, have = {me()}, {[me()]=true} + for _, peer in ipairs(sdk.net.players()) do + local id=tostring(peer) + if not have[id] then ids[#ids+1],have[id]=id,true end end - if not have[me()] then ids[#ids + 1] = me() end table.sort(ids) + -- The transport host starts, regardless of numeric/lexicographic peer order. + local first=host() and me() or tostring(info().host_id) + for i,id in ipairs(ids) do if id==first then table.remove(ids,i);table.insert(ids,1,id);break end end return ids end local function name(id) - if id == me() then return "You" end - local s = sdk.player.skater(id) - return s and s.name or ("Player " .. tostring(id)) + if id==me() then return "You" end + local s=sdk.player.skater(id) + return s and s.name or ("Player "..tostring(id)) end local function canon(s) return tostring(s or ""):lower():gsub("[^%w]", "") end -local function allowed(s) - local c = canon(s) - return c ~= "" and (sdk.settings.allow_ollie or c ~= "ollie" and c ~= "nollie") -end -local function alive(s) - local ids = {} - for _, id in ipairs(s.ids) do - if (s.l[id] or 0) < 5 then ids[#ids + 1] = id end - end - return ids -end +local function allowed(t) local c=canon(t);return c~="" and (sdk.settings.allow_ollie or c~="ollie" and c~="nollie") end +local function index(ids,id) for i,p in ipairs(ids) do if p==id then return i end end return 0 end +local function count(s) local n=0;for _,id in ipairs(s.ids) do if (s.l[id] or 0)<5 then n=n+1 end end return n end local function next_setter(s) - local ids = alive(s) - for i, id in ipairs(ids) do if id == s.a then return ids[i % #ids + 1] end end - return ids[1] or s.ids[1] + local at=index(s.ids,s.a) + for step=1,#s.ids do local id=s.ids[(at+step-1)%#s.ids+1];if (s.l[id] or 0)<5 then return id end end + return s.ids[1] end -local function copying(s, id) - return #s.ids == 1 or id ~= s.a -end - --- Sample everybody every tick, including spectators/setters. Joining or loading --- establishes a baseline; old results can never satisfy a new turn. -local function events(ids) - local out, present = {}, {} - local skaters = sdk.player.skaters() - for _, id in ipairs(ids) do - present[id] = true - local s = skaters[id] - if s then - local landing, bail = tonumber(s.landing_seq) or 0, tonumber(s.bail_seq) or 0 - local previous = seen[id] - if previous then - if bail > previous.bail then out[id] = {bail=true} - elseif landing > previous.landing and not s.bailing then - local trick = tostring(s.landed_trick or "") - if trick ~= "" then out[id] = {trick=trick} end - end - end - seen[id] = {landing=landing, bail=bail} - end +local function next_copier(s) + for _,id in ipairs(s.ids) do + if (s.solo or id~=s.a) and (s.l[id] or 0)<5 and not s.d[id] and not s.f[id] then return id end end - for id in pairs(seen) do if not present[id] then seen[id] = nil end end - return out end - --- Compact per-roster strings keep ten-player state below the 512-byte Lua limit. local function publish(s) - local letters, done, failed = {}, {}, {} - for _, id in ipairs(s.ids) do - letters[#letters+1] = tostring(s.l[id] or 0) - done[#done+1] = s.d[id] and "1" or "0" - failed[#failed+1] = s.f[id] and "1" or "0" + local l,d,f={},{},{} + for _,id in ipairs(s.ids) do + l[#l+1]=tostring(s.l[id] or 0);d[#d+1]=s.d[id] and "1" or "0";f[#f+1]=s.f[id] and "1" or "0" end - sdk.net.publish("skate", {p=s.p, n=s.n, a=s.a, t=s.t, w=s.w, - r=math.ceil(math.max(0, s.u-sdk.time.elapsed)), ids=s.ids, - l=table.concat(letters), d=table.concat(done), f=table.concat(failed)}) - last_publish = sdk.time.elapsed + -- Split immutable roster/start pose from turn state; each value stays <512B. + sdk.net.publish("origin",{e=s.e,v=s.v,ids=s.ids,p=s.origin.position,h=s.origin.heading}) + sdk.net.publish("skate",{e=s.e,v=s.v,q=s.q,p=s.p,n=s.n,a=index(s.ids,s.a),c=index(s.ids,s.c), + t=s.t,w=index(s.ids,s.w),g=s.g,r=math.ceil(math.max(0,(s.u or 0)-sdk.time.elapsed)), + l=table.concat(l),d=table.concat(d),f=table.concat(f),error=s.error}) + last_publish=sdk.time.elapsed end local function read_game() - if info().is_host or not info().active then return game end - local wire = sdk.net.read(tostring(info().host_id), "skate") - if type(wire) ~= "table" or type(wire.ids) ~= "table" or type(wire.l) ~= "string" then return nil end - local s = {p=wire.p, n=wire.n, a=wire.a, t=wire.t, w=wire.w, r=wire.r, ids=wire.ids, l={}, d={}, f={}} - for i, id in ipairs(s.ids) do - s.l[id] = tonumber(wire.l:sub(i,i)) or 0 - s.d[id] = tostring(wire.d):sub(i,i) == "1" - s.f[id] = tostring(wire.f):sub(i,i) == "1" - end + if host() then return game end + local peer=tostring(info().host_id) + if received_host~=peer then received,received_host=nil,peer end + local w,o=sdk.net.read(peer,"skate"),sdk.net.read(peer,"origin") + if type(w)~="table" or type(o)~="table" then received=nil;return nil end + if w.e~=o.e or w.v~=o.v then return received end + if type(o.ids)~="table" or type(w.l)~="string" then return nil end + local s={e=w.e,q=w.q,p=w.p,n=w.n,ids=o.ids,a=o.ids[w.a],c=o.ids[w.c],t=w.t,w=o.ids[w.w],g=w.g,r=w.r,error=w.error, + origin={position=o.p,heading=o.h,velocity={0,0,0}},l={},d={},f={}} + for i,id in ipairs(s.ids) do s.l[id]=tonumber(w.l:sub(i,i)) or 0;s.d[id]=tostring(w.d):sub(i,i)=="1";s.f[id]=tostring(w.f):sub(i,i)=="1" end + received=s return s end -local function begin_set(s, setter) - s.p, s.a, s.t, s.d, s.f = "s", setter, "", {}, {} - s.u = sdk.time.elapsed + 90 +local function release() + if applied then + sdk.player.suspend(false);sdk.camera.watch(nil) + if restore then sdk.player.teleport(restore) end + sdk.net.publish("ack",nil) + end + applied,released,ack,restore=nil,nil,nil,nil end -local function fail(s, id) - if not s.d[id] and not s.f[id] then - s.f[id], s.l[id] = true, math.min(5, (s.l[id] or 0) + 1) +local function control(s, fixed) + if not s or s.p=="i" or s.p=="w" then release();return end + local token=tostring(s.e)..":"..tostring(s.q) + if applied~=token then + if not restore then local p=sdk.player.read();restore={position=p.position,heading=p.heading,velocity={0,0,0}} end + applied,released,ack=token,nil,nil + sdk.player.suspend(true) + sdk.camera.watch(s.c~=me() and s.c or nil) + end + if fixed and not ack then + -- Acknowledge the suspension on the next command frame. + ack={e=s.e,q=s.q,stage=0} + sdk.net.publish("ack",ack) + end + if s.g and s.c==me() and released~=token then + sdk.player.teleport(s.origin) + sdk.player.suspend(false) + sdk.camera.watch(nil) + released,armed_at=token,sdk.time.elapsed+0.35 + end + if fixed and released==token and ack and ack.stage==0 and sdk.time.elapsed>=armed_at then + local p=sdk.player.read() + ack={e=s.e,q=s.q,stage=1,l=p.landing_seq or 0,b=p.bail_seq or 0} + sdk.net.publish("ack",ack) end end -local function stopped(ids) - return {p="i",n=0,a=ids[1],t="",w="",ids=ids,l={},d={},f={},u=sdk.time.elapsed} +local function turn(s,id,phase) + s.c,s.p,s.q,s.g,s.base=id,phase,s.q+1,false,nil + s.u=sdk.time.elapsed+15 +end +local function begin_set(s,id) + s.a,s.t,s.d,s.f=id,"",{},{} + turn(s,id,"s") +end +local function result(s) + local id=next_copier(s) + if id then turn(s,id,"c") else s.p,s.g,s.u="r",false,sdk.time.elapsed+2;s.q=s.q+1 end +end +local function read_ack(id) if id==me() then return ack end return sdk.net.read(id,"ack") end +local function matches(a,s) return type(a)=="table" and a.e==s.e and a.q==s.q end +local function host_tick() + local s,now=game,sdk.time.elapsed + if not s or not running then return end + local present={};for _,id in ipairs(roster()) do present[id]=true end + local changed=false + for i=#s.ids,1,-1 do if not present[s.ids[i]] then table.remove(s.ids,i);changed=true end end + if changed then + s.v=s.v+1 + if not present[s.a] then begin_set(s,next_setter(s)) + elseif not present[s.c] and s.p=="c" then result(s) end + publish(s) + end + if s.p=="r" and now>=s.u then + if not s.solo and count(s)<=1 then + s.p,s.g="w",false + for _,id in ipairs(s.ids) do if (s.l[id] or 0)<5 then s.w=id end end + running=false + else s.n=s.n+1;begin_set(s,next_setter(s)) end + publish(s);return + end + if s.p=="s" or s.p=="c" then + if not s.g then + local all=true + for _,id in ipairs(s.ids) do if not matches(read_ack(id),s) then all=false end end + if all then s.g=true;s.u=now+15;publish(s) + elseif now>=s.u then s.p,s.error,running="i","A player did not acknowledge the turn. Enable the same mod on every player and restart.",false;publish(s) end + return + end + if not s.base then + local a=read_ack(s.c) + if matches(a,s) and a.stage==1 then s.base={landing=a.l,bail=a.b};s.u=now+(s.p=="s" and 90 or (sdk.settings.copy_seconds or 45));publish(s) + elseif now>=s.u then s.p,s.error,running="i","Active player did not finish resetting. Restart the session.",false;publish(s) end + return + end + local p=sdk.player.skater(s.c) + if p then + local landed,bail=p.landing_seq or 0,p.bail_seq or 0 + local failed=bail>s.base.bail or now>=s.u + local trick=landed>s.base.landing and not p.bailing and tostring(p.landed_trick or "") or nil + if s.p=="s" then + if failed then begin_set(s,next_setter(s));publish(s) + elseif trick and allowed(trick) then s.t=trick;result(s);publish(s) + elseif trick then s.base.landing=landed end + elseif failed or trick then + if not failed and canon(trick)==canon(s.t) then s.d[s.c]=true + else s.f[s.c]=true;s.l[s.c]=math.min(5,(s.l[s.c] or 0)+1) end + result(s);publish(s) + end + elseif now>=s.u then s.p,s.error,running="i","Active player observations are missing. Restart the session.",false;publish(s) end + end + if now-last_publish>=0.5 then publish(s) end end local function menu(force) - local host = not info().active or info().is_host - local stamp=tostring(host)..tostring(running) + local stamp=tostring(host())..tostring(running) if not force and stamp==menu_stamp then return end menu_stamp=stamp - sdk.ui.menu("session", {title="SKATE",section="Gamemodes",items={ - {id="start",label="Start session",enabled=host and not running,description=not host and "Only the multiplayer host can start this session. Ask the host to start it, or leave multiplayer for a solo drill." or (running and "A session is already running. Stop it before starting a new one." or "Start a new game; solo starts a set-and-match drill.")}, - {id="stop",label="Stop session",enabled=host and running,description=not host and "Only the multiplayer host can stop this session." or (running and "End the current game for everyone." or "No session is running.")}, + sdk.ui.menu("session",{title="SKATE",section="Gamemodes",items={ + {id="start",label="Start session",enabled=host() and not running,description=host() and "Host starts here. Other players take turns from this position." or "Only the multiplayer host can start this session."}, + {id="stop",label="Stop session",enabled=host() and running,description="End the match and restore everyone's skater and camera."}, }}) end -local function host_tick(ids, observed) - local now = sdk.time.elapsed - if not game or game.p == "w" and now >= game.u then - game = {n=1, ids=ids, l={}, w=""} - begin_set(game, ids[1]) - publish(game) - return - end - local s = game - s.ids = ids - local present = {} - for _, id in ipairs(ids) do present[id] = true; s.l[id] = s.l[id] or 0 end - for id in pairs(s.l) do if not present[id] then s.l[id], s.d[id], s.f[id] = nil, nil, nil end end - if not present[s.a] then begin_set(s, next_setter(s)); publish(s); return end - if s.p == "r" and now >= s.u then - local live = alive(s) - if #live == 0 or #ids > 1 and #live == 1 then - s.p, s.w, s.u = "w", live[1] or "", now + 8 - else - s.n = s.n + 1 - begin_set(s, next_setter(s)) - end - publish(s) - return - end - if s.p == "s" then - local e = observed[s.a] - if e and e.trick and allowed(e.trick) then - s.p, s.t, s.d, s.f = "c", e.trick, {}, {} - s.u = now + (sdk.settings.copy_seconds or 45) - publish(s) - return - elseif now >= s.u or e and e.bail then - begin_set(s, next_setter(s)) - publish(s) - return - end - elseif s.p == "c" then - local changed, pending = false, 0 - for _, id in ipairs(alive(s)) do - if copying(s, id) and not s.d[id] and not s.f[id] then - local e = observed[id] - if e and e.trick and canon(e.trick) == canon(s.t) then s.d[id], changed = true, true - elseif e or now >= s.u then fail(s, id); changed = true - else pending = pending + 1 end - end - end - if pending == 0 then s.p, s.u, changed = "r", now+2, true end - if changed then publish(s); return end - end - if now-last_publish >= 1 then publish(s) end -end -local function overlay() - local s, id = read_game(), me() - local status, board = "Waiting for the host...", {} +local function overlay(s) + local status,board="No host match received. Ask the host to start SKATE; all players must enable the same mod version.",{} if s then - for _, peer in ipairs(s.ids) do - local letters = WORD:sub(1, s.l[peer] or 0) - board[#board+1] = name(peer) .. (peer == s.a and " * " or " ") .. (letters ~= "" and letters or "-") - end - local remaining = s.r or math.ceil(math.max(0, (s.u or 0)-sdk.time.elapsed)) - if s.p == "i" then status = "Session stopped. Open Gamemodes > SKATE to start." - elseif s.p == "s" then status = name(s.a) .. ": land a trick to set it." - elseif s.p == "w" then status = s.w ~= "" and (name(s.w) .. " wins. Restarting shortly.") or "Drill finished. Restarting shortly." - elseif s.d[id] then status = "Matched " .. s.t .. "." - elseif s.f[id] then status = "Missed " .. s.t .. "." - elseif copying(s, id) then status = "Copy: " .. s.t .. " (" .. remaining .. "s)" - else status = "Set: " .. s.t .. ". Other skaters are copying." end + for _,id in ipairs(s.ids) do local l=WORD:sub(1,s.l[id] or 0);board[#board+1]=name(id)..(id==s.c and " > " or " ")..(l~="" and l or "-") end + if s.p=="i" then status=s.error or "Session stopped. Open Gamemodes > SKATE to start." + elseif s.p=="w" then status=s.w and name(s.w).." wins!" or "Session finished." + elseif s.p=="r" then status="Round complete: "..s.t + elseif not s.g then status="Preparing "..name(s.c).."'s turn; synchronizing players." + elseif s.p=="s" then status=name(s.c)..": land a trick to set it." + else status=name(s.c)..": copy "..s.t.." ("..tostring(s.r or math.ceil(math.max(0,s.u-sdk.time.elapsed))).."s)" end + if s.c~=me() and s.p~="i" and s.p~="w" then status=status.." Spectating." end end - sdk.ui.text("skate_title", "GAME OF SKATE") - sdk.ui.text("skate_status", status) - sdk.ui.text("skate_board", table.concat(board, " ")) - local last = tostring(sdk.player.read().landed_trick or "") - sdk.ui.text("skate_last", "Last landed trick: " .. (last ~= "" and last or "(none)")) - sdk.ui.text("skate_net", #roster() == 1 and "Solo drill: set a trick, then land it again." or "") + sdk.ui.text("skate_title","GAME OF SKATE") + sdk.ui.text("skate_status",status) + sdk.ui.text("skate_board",table.concat(board," ")) + sdk.ui.text("skate_last","Last landed trick: "..tostring(sdk.player.read().landed_trick or "")) + sdk.ui.text("skate_net",tostring(info().status or "")) end return { - on_load = function() - assert((sdk.capabilities.skater or 0) >= 4, "Update the game: confirmed scoring observations are required") + on_load=function() + assert((sdk.capabilities.player_control or 0)>=1 and (sdk.capabilities.camera or 0)>=3,"Update the game: player suspension and peer camera streaming are required") menu(true) - sdk.log("Game of SKATE ready; start it from Gamemodes > SKATE") end, - on_ui_update = function() - if info().active and not info().is_host then running=false;game=nil end - menu() + on_ui_update=function() + if not host() then running=false;game=nil end + local s=read_game();control(s,false);overlay(s);menu() end, - on_fixed_update = function() - local ids = roster() - local observed = events(ids) - if not info().active or info().is_host then - if running then host_tick(ids, observed) - elseif not game or game.p ~= "i" or sdk.time.elapsed-last_publish>=1 then game=stopped(ids);publish(game) end - else game=nil;running=false end - menu() - overlay() + on_fixed_update=function() + local s=read_game();control(s,true) + if host() then host_tick() end + s=read_game();control(s,false);overlay(s);menu() end, - on_event = function(event) - if event.name == "world_changed" then - seen,game,running={},nil,false; sdk.net.publish("skate",nil);menu(true) - elseif event.name == "menu_action" and event.menu == "session" then - if info().active and not info().is_host then sdk.log("Only the host can start or stop this session");return end - if event.item == "start" then - seen,game,running={},nil,true - host_tick(roster(),{}) - menu() - elseif event.item == "stop" then - running=false;game=stopped(roster());publish(game);menu() - end + on_event=function(event) + if event.name=="world_changed" then release();received,received_host=nil,nil;game,running=nil,false;sdk.net.publish("skate",nil);sdk.net.publish("origin",nil);menu(true) + elseif event.name=="menu_action" and event.menu=="session" and host() then + if event.item=="start" and not running then + release();serial=serial+1 + local p=sdk.player.read() + game={e=math.floor(sdk.time.elapsed*1000)*1000+serial,v=1,q=0,n=1,ids=roster(),l={},w=nil, + origin={position=p.position,heading=p.heading or 0,velocity={0,0,0}}} + game.solo=#game.ids==1;running=true;begin_set(game,me());publish(game);control(game,false);menu() + elseif event.item=="stop" and game then game.p,game.g,running="i",false,false;publish(game);release();menu() end end end, - on_unload = function() - for _, key in ipairs({"skate_title", "skate_status", "skate_board", "skate_last", "skate_net"}) do sdk.ui.text(key, "") end - sdk.net.publish("skate", nil) - sdk.ui.remove_menu("session") + on_unload=function() + release();sdk.net.publish("skate",nil);sdk.net.publish("origin",nil);sdk.ui.remove_menu("session") + for _,key in ipairs({"skate_title","skate_status","skate_board","skate_last","skate_net"}) do sdk.ui.text(key,"") end end, } diff --git a/mods/game-of-skate/mod.json b/mods/game-of-skate/mod.json index 108f92a5..cb937bc0 100644 --- a/mods/game-of-skate/mod.json +++ b/mods/game-of-skate/mod.json @@ -2,9 +2,9 @@ "id": "examples.game-of-skate", "api": 2, "name": "Game of SKATE", - "version": "0.2.0", + "version": "0.3.0", "author": "Skate3clone", - "description": "One skater sets a trick. Everyone else has to match it or take a letter. Last player without SKATE wins. Works solo as a set-then-match drill.", + "description": "Host-first sequential turns from a shared start. Waiting players are suspended and watch the active player's camera. Match the trick or take a letter; last player without SKATE wins.", "entry": "main.lua", "settings": { "copy_seconds": { diff --git a/mods/simon-says/README.md b/mods/simon-says/README.md new file mode 100644 index 00000000..c3169fb8 --- /dev/null +++ b/mods/simon-says/README.md @@ -0,0 +1,23 @@ +# Simon Says 2.0 + +Enable this version on every player, using the updated game build. The host +starts/stops from **Gamemodes > Simon Says**. Everyone races to land the called +trick; the first confirmed clean ride-away observed by the host wins the round. +Others lose a life. Solo plays as survival against the clock. Category filters, +round timing, escalation, sudden death, hints and the call card remain settings. +Eliminated and late-joining players spectate a surviving player. Stop, match end, +world changes and unloading release the camera and player suspension. + +Each player acknowledges a new round before its results can count. The host +reads replicated engine landing/bail counters and settled scoring metadata; +there is no stick-gesture win detector or client-submitted win claim. Body spins +are separate from board shuvit rotation. A bail during ride-away cancels credit. +All peers need this version and the new engine observation fields. + +Caveman and Acid Drop guide entries are excluded from random calls because the +current extracted scoring catalog has no distinct settled labels for them. +The duplicate FS Fastplant card is de-duplicated. Ground tricks must finish and +produce a confirmed scoring result; merely entering a manual/plant does not win. + +Start/Stop now use the game menu instead of the old ready/reset hotkeys. The +obsolete stick-detector minimum-airtime setting has been removed. diff --git a/mods/simon-says/SHA256SUMS.txt b/mods/simon-says/SHA256SUMS.txt new file mode 100644 index 00000000..e36a7f80 --- /dev/null +++ b/mods/simon-says/SHA256SUMS.txt @@ -0,0 +1,3 @@ +a3f3c066b97ab4318fa58dc4f31d897f498c5cfd9da716c126d836f2ed4069ad main.lua +da06ca442a0270968341e9bb54cd1eec34987046f9434e8f09254ae2c074d7d9 mod.json +b0fe8ba660657ac6ca84e98735fc362ca7ab6c41dc22d73ed2ac50fbe1911e2e README.md diff --git a/mods/simon-says/main.lua b/mods/simon-says/main.lua new file mode 100644 index 00000000..f19e39a7 --- /dev/null +++ b/mods/simon-says/main.lua @@ -0,0 +1,280 @@ +-- Host-authoritative race to copy; only settled engine results count. +local POOL = { + { name = "Kickflip", cat = "flip", hint = "RS down, flick UP+RIGHT", ghint = "RS down, flick UP+LEFT", color = { 0.23, 0.79, 0.98, 1 } }, + { name = "Heelflip", cat = "flip", hint = "RS down, flick UP+LEFT", ghint = "RS down, flick UP+RIGHT", color = { 1.00, 0.62, 0.15, 1 } }, + { name = "Pop Shuvit", cat = "shuvit", hint = "RS scoop RIGHT side", ghint = "RS scoop LEFT side", color = { 0.70, 0.50, 1.00, 1 } }, + { name = "360 Flip", cat = "flip", hint = "RS scoop L -> DOWN -> UR", ghint = "RS scoop R -> DOWN -> UL", color = { 1.00, 0.84, 0.20, 1 } }, + { name = "Nose Grab", cat = "grab", hint = "AIR: hold LT + RS DOWN", ghint = "AIR: hold RT + RS DOWN", color = { 0.35, 0.95, 0.45, 1 } }, + { name = "Ollie", cat = "ollie", hint = "RS down, pop straight UP", ghint = "RS down, pop straight UP", color = { 0.75, 0.85, 1.00, 1 } }, + { name = "Seatbelt", cat = "grab", hint = "AIR: hold LT + RS UP", ghint = "AIR: hold RT + RS UP", color = { 1.00, 0.45, 0.75, 1 } }, + { name = "Nollie", cat = "ollie", hint = "RS tap UP then DOWN", ghint = "RS tap UP then DOWN", color = { 0.80, 0.85, 0.90, 1 } }, + { name = "Varial Kickflip", cat = "flip", hint = "RS down-LEFT to UP+RIGHT", ghint = "RS down-RIGHT to UP+LEFT", color = { 0.20, 0.90, 0.80, 1 } }, + { name = "Varial Heelflip", cat = "flip", hint = "RS down-RIGHT to UP+LEFT", ghint = "RS down-LEFT to UP+RIGHT", color = { 1.00, 0.45, 0.30, 1 } }, + { name = "360 Pop Shuvit", cat = "shuvit", hint = "RS scoop L,DOWN,RIGHT", ghint = "RS scoop R,DOWN,LEFT", color = { 0.85, 0.70, 1.00, 1 } }, + { name = "FS Pop Shuvit", cat = "shuvit", hint = "RS scoop LEFT side", ghint = "RS scoop RIGHT side", color = { 0.55, 0.65, 1.00, 1 } }, + { name = "Double Kickflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 0.30, 0.70, 1.00, 1 } }, + { name = "Double Heelflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 1.00, 0.55, 0.20, 1 } }, + { name = "Nollie Kickflip", cat = "flip", hint = "Nollie: flick to heelside", ghint = "Nollie: flick to heelside", color = { 0.45, 0.85, 0.90, 1 } }, + { name = "Nollie Heelflip", cat = "flip", hint = "Nollie: flick to toeside", ghint = "Nollie: flick to toeside", color = { 0.90, 0.60, 0.35, 1 } }, + { name = "Hardflip", cat = "flip", hint = "FS shuvit + kickflip. Awkward!", ghint = "FS shuvit + kickflip. Awkward!", color = { 1.00, 0.30, 0.45, 1 } }, + { name = "Laser Flip", cat = "flip", hint = "FS 360 shuvit + heelflip", ghint = "FS 360 shuvit + heelflip", color = { 0.60, 1.00, 0.40, 1 } }, + { name = "Inward Heelflip", cat = "flip", hint = "Shuvit + heelflip", ghint = "Shuvit + heelflip", color = { 1.00, 0.50, 0.60, 1 } }, + { name = "360 Hardflip", cat = "flip", hint = "FS 360 shuvit + kickflip", ghint = "FS 360 shuvit + kickflip", color = { 0.95, 0.35, 0.55, 1 } }, + { name = "Melon", cat = "grab", hint = "Grab BS, push board forwards", ghint = "Grab BS, push board forwards", color = { 0.40, 0.90, 0.50, 1 } }, + { name = "Method", cat = "grab", hint = "Grab and tweak it out", ghint = "Grab and tweak it out", color = { 0.50, 1.00, 0.60, 1 } }, + { name = "Mute", cat = "grab", hint = "Board sideways, front grab", ghint = "Board sideways, front grab", color = { 0.65, 0.95, 0.45, 1 } }, + { name = "Stale", cat = "grab", hint = "Board sideways, back grab", ghint = "Board sideways, back grab", color = { 0.75, 0.90, 0.40, 1 } }, + { name = "Crail", cat = "grab", hint = "Nose reach, watch the hand", ghint = "Nose reach, watch the hand", color = { 0.45, 0.85, 0.55, 1 } }, + { name = "Tail Grab", cat = "grab", hint = "Push forward, back-hand grab", ghint = "Push forward, back-hand grab", color = { 0.55, 0.80, 0.50, 1 } }, + { name = "50-50", cat = "grind", fam = true, hint = "Both trucks. Line up rail", ghint = "Both trucks. Line up rail", color = { 0.90, 0.90, 0.95, 1 } }, + { name = "Smith Grind", cat = "grind", fam = true, hint = "Line up Smith and grind", ghint = "Line up Smith and grind", color = { 0.88, 0.86, 0.90, 1 } }, + { name = "Krooked", cat = "grind", fam = true, hint = "Nose into it, crooked grind", ghint = "Nose into it, crooked grind", color = { 0.86, 0.80, 0.92, 1 } }, + { name = "Nose Blunt", cat = "grind", fam = true, hint = "Nose up onto the blunt", ghint = "Nose up onto the blunt", color = { 0.84, 0.82, 0.94, 1 } }, + { name = "Feeble", cat = "grind", fam = true, hint = "Front truck hangs feeble", ghint = "Front truck hangs feeble", color = { 0.82, 0.86, 0.92, 1 } }, + { name = "Salad", cat = "grind", fam = true, hint = "Salad grind, hold it", ghint = "Salad grind, hold it", color = { 0.80, 0.90, 0.88, 1 } }, + { name = "Blunt Slide", cat = "grind", fam = true, hint = "Up on the blunt, slide it", ghint = "Up on the blunt, slide it", color = { 0.87, 0.83, 0.93, 1 } }, + { name = "Willie", cat = "grind", fam = true, hint = "Willie grind, steezy", ghint = "Willie grind, steezy", color = { 0.83, 0.89, 0.91, 1 } }, + { name = "Over-Krooks", cat = "grind", fam = true, also = { "overkrook", "overkrooked", "krooks" }, hint = "Over the krooks", ghint = "Over the krooks", color = { 0.85, 0.81, 0.93, 1 } }, + { name = "Nose Manual", ground = true, cat = "manual", fam = true, hint = "Push partway, sweet spot", ghint = "Push partway, sweet spot", color = { 0.70, 0.95, 0.85, 1 } }, + { name = "BS Fastplant", ground = true, cat = "manual", fam = true, hint = "BS grab, push off back foot", ghint = "BS grab, push off back foot", color = { 0.95, 0.75, 0.45, 1 } }, + { name = "BS Handplant", ground = true, cat = "manual", fam = true, hint = "Ramp angle, hold at edge", ghint = "Ramp angle, hold at edge", color = { 0.93, 0.70, 0.50, 1 } }, + { name = "FS 360 Pop Shuvit", cat = "shuvit", hint = "Find the start spot", ghint = "Find the start spot", color = { 0.65, 0.60, 1.00, 1 } }, + { name = "Nollie 360 Flip", cat = "flip", hint = "Nollie shuvit + kickflip", ghint = "Nollie shuvit + kickflip", color = { 0.50, 0.80, 0.95, 1 } }, + { name = "FS Fastplant", ground = true, cat = "manual", fam = true, hint = "FS grab, push off front foot", ghint = "FS grab, push off front foot", color = { 0.94, 0.78, 0.42, 1 } }, + { name = "Triple Kickflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 0.25, 0.65, 1.00, 1 } }, + { name = "Triple Heelflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 1.00, 0.50, 0.25, 1 } }, + { name = "360 Spin", cat = "spin", spin = 360, hint = "Spin 360 either way, stomp it", ghint = "Spin 360 either way, stomp it", color = { 0.40, 1.00, 0.70, 1 } }, + { name = "540 Spin", cat = "spin", spin = 540, hint = "Spin 540 either way, stomp it", ghint = "Spin 540 either way, stomp it", color = { 0.45, 0.95, 0.75, 1 } }, + { name = "720 Spin", cat = "spin", spin = 720, hint = "Spin 720 either way, stomp it", ghint = "Spin 720 either way, stomp it", color = { 0.50, 0.90, 0.80, 1 } }, + { name = "Kickflip 180", cat = "combo", combo = { flip = "KICKFLIP", pool = 1, spin = 180 }, hint = "Kickflip + spin 180 either way", ghint = "Kickflip + spin 180 either way", color = { 0.30, 0.75, 1.00, 1 } }, + { name = "Kickflip 360", cat = "combo", combo = { flip = "KICKFLIP", pool = 1, spin = 360 }, hint = "Kickflip + spin 360 either way", ghint = "Kickflip + spin 360 either way", color = { 0.28, 0.72, 1.00, 1 } }, + { name = "Heelflip 180", cat = "combo", combo = { flip = "HEELFLIP", pool = 2, spin = 180 }, hint = "Heelflip + spin 180 either way", ghint = "Heelflip + spin 180 either way", color = { 1.00, 0.58, 0.22, 1 } }, + { name = "Heelflip 360", cat = "combo", combo = { flip = "HEELFLIP", pool = 2, spin = 360 }, hint = "Heelflip + spin 360 either way", ghint = "Heelflip + spin 360 either way", color = { 1.00, 0.55, 0.20, 1 } }, + { name = "Ollie 180", cat = "combo", combo = { flip = "OLLIE", pool = 6, spin = 180 }, hint = "Ollie + spin 180 either way", ghint = "Ollie + spin 180 either way", color = { 0.72, 0.82, 1.00, 1 } }, + { name = "Ollie 360", cat = "combo", combo = { flip = "OLLIE", pool = 6, spin = 360 }, hint = "Ollie + spin 360 either way", ghint = "Ollie + spin 360 either way", color = { 0.70, 0.80, 1.00, 1 } }, + { name = "360 Flip 180", cat = "combo", combo = { flip = "360_FLIP", pool = 4, spin = 180 }, hint = "Treflip + spin 180 either way", ghint = "Treflip + spin 180 either way", color = { 0.95, 0.80, 0.25, 1 } }, + { name = "Melon 360", cat = "combo", combo = { flip = "MELON", pool = 21, spin = 360 }, hint = "Melon + spin 360 either way", ghint = "Melon + spin 360 either way", color = { 0.42, 0.88, 0.52, 1 } }, + { name = "Nose Grab 360", cat = "combo", combo = { flip = "NOSE_GRAB", pool = 5, spin = 360 }, hint = "Nose grab + spin 360 either way", ghint = "Nose grab + spin 360 either way", color = { 0.38, 0.92, 0.48, 1 } }, + { name = "Method 360", cat = "combo", combo = { flip = "METHOD", pool = 22, spin = 360 }, hint = "Method + spin 360 either way", ghint = "Method + spin 360 either way", color = { 0.52, 0.98, 0.62, 1 } }, + { name = "Superdude", cat = "grab", hint = "Grab + superman out huge", ghint = "Grab + superman out huge", color = { 0.60, 0.90, 0.50, 1 } }, + { name = "Miracle Whip", cat = "grab", hint = "Whip it. It's a grab.", ghint = "Whip it. It's a grab.", color = { 0.65, 0.88, 0.48, 1 } }, + { name = "Late Kickflip", cat = "flip", hint = "Ollie first, flip late", ghint = "Ollie first, flip late", color = { 0.32, 0.72, 1.00, 1 } }, + { name = "Late Heelflip", cat = "flip", hint = "Ollie first, flip late", ghint = "Ollie first, flip late", color = { 1.00, 0.57, 0.23, 1 } }, + { name = "Late Shuvit", cat = "shuvit", hint = "Ollie first, shuvit late", ghint = "Ollie first, shuvit late", color = { 0.72, 0.52, 1.00, 1 } }, + { name = "Kickflip Underflip", cat = "flip", hint = "Flip it, then flip it back", ghint = "Flip it, then flip it back", color = { 0.34, 0.70, 1.00, 1 } }, + { name = "Heelflip Underflip", cat = "flip", hint = "Flip it, then flip it back", ghint = "Flip it, then flip it back", color = { 1.00, 0.53, 0.25, 1 } }, + { name = "Nollie Pop Shuvit", cat = "shuvit", hint = "Nollie stance shuvit", ghint = "Nollie stance shuvit", color = { 0.68, 0.62, 1.00, 1 } }, + { name = "FS Fastplant", ground = true, cat = "manual", fam = true, hint = "FS grab, push front foot", ghint = "FS grab, push front foot", color = { 0.94, 0.76, 0.44, 1 } }, + { name = "Airwalk", cat = "grab", hint = "Airwalk it out", ghint = "Airwalk it out", color = { 0.62, 0.89, 0.49, 1 } }, + { name = "Coffin", ground = true, cat = "grab", hint = "Lay back, feet up. Coffin", ghint = "Lay back, feet up. Coffin", color = { 0.58, 0.86, 0.52, 1 } }, + { name = "Cross Bone", cat = "grab", hint = "Cross-bone grab", ghint = "Cross-bone grab", color = { 0.66, 0.87, 0.47, 1 } }, + { name = "Caveman", ground = true, cat = "ollie", hint = "Step off, jump back on", ghint = "Step off, jump back on", color = { 0.74, 0.84, 1.00, 1 } }, + { name = "Acid Drop", ground = true, cat = "ollie", hint = "Ride off a drop", ghint = "Ride off a drop", color = { 0.73, 0.83, 1.00, 1 } }, +} + +local game, received, epoch = nil, nil, 0 +local last_publish, menu_stamp, watching = -1, nil, false +local deck = {} +local acknowledged +local function option(k,d) local v=sdk.settings[k];if v==nil then return d end;return v end +local function net() return sdk.net.info() end +local function me() return tostring(net().local_id or "0") end +local function host() return not net().active or net().is_host end +local function now() return sdk.time.elapsed end +local function canon(s) + s=tostring(s or ""):lower():gsub("id_trick_",""):gsub("_"," ") + for _,category in ipairs({"ground","trick","flip","grab","grind","authentic","air","metrics"}) do + s=s:gsub("%f[%w]"..category.."%f[%W]","") + end + s=s:gsub("%f[%w]switch%f[%W]",""):gsub("%f[%w]fakie%f[%W]","") + return (s:gsub("[^%w]","")) +end +local aliases={stalegrab="stale",stalefish="stale",crailgrab="crail",seatbeltgrab="seatbelt", + mutegrab="mute",superman="superdude",ngairwalk="airwalk",crossbone="crossbone", + crooked="krooked",crookedgrind="krooked",krooks="krooked",nosebluntslide="noseblunt", + popshoveit="popshuvit",shuvit="popshuvit",["360shuvit"]="360popshuvit"} +local function key(s) local k=canon(s);return aliases[k] or k end +local function matches(label,t,p) + -- The scorer appends body rotation after the board/grab name. A leading + -- 360 in "360 Flip" is a board trick, never evidence of a body spin. + local spin=tonumber(tostring(label):match("%s(%d+)%s*$")) + local base=tostring(label):gsub("%s%d+%s*$","") + if p.landed_spin_degrees~=nil then spin=math.abs(p.landed_spin_degrees) end + local raw=p.landed_trick_base or "" + if base:lower():find("halfcab",1,true) then spin=180;base=base:gsub("[FfBb][Ss] ?[Hh]alf[Cc]ab","") end + if t.spin then + return spin==t.spin + end + if t.combo then + return spin==t.combo.spin and (key(base:gsub("^[FfBb][Ss] ",""))==key(POOL[t.combo.pool].name) or raw~="" and key(raw)==key(POOL[t.combo.pool].name)) + end + if t.cat=="grind" or t.cat=="grab" then base=base:gsub("^[FfBb][Ss] ","") end + local k=key(base) + if k==key(t.name) or raw~="" and key(raw)==key(t.name) then return true end + for _,a in ipairs(t.also or {}) do if k==key(a) then return true end end + return false +end +local function enabled(t) + -- The extracted scoring catalog has no settled Caveman/Acid Drop labels. + -- Keep their guide entries, but never issue an unconfirmable challenge. + if t.name=="Caveman" or t.name=="Acid Drop" then return false end + local keys={flip="allow_flips",shuvit="allow_shuvits",ollie="allow_ollies",grab="allow_grabs",grind="allow_grinds",manual="allow_manuals_plants",spin="allow_spins"} + if t.combo then return option("allow_spins",true) and enabled(POOL[t.combo.pool]) end + return option(keys[t.cat],true) +end +local function draw() + if #deck==0 then + local seen={} + for i,t in ipairs(POOL) do if enabled(t) and not seen[t.name] then deck[#deck+1]=i;seen[t.name]=true end end + for i=#deck,2,-1 do local j=math.random(i);deck[i],deck[j]=deck[j],deck[i] end + end + return table.remove(deck) +end +local function publish() + if not game then sdk.net.publish("match",nil);return end + local l={};for _,id in ipairs(game.ids) do l[#l+1]=tostring(game.l[id] or 0) end + sdk.net.publish("match",{e=game.e,p=game.p,n=game.n,c=game.c,ids=game.ids,l=table.concat(l), + r=math.ceil(math.max(0,game.until_at-now())),w=game.w or 0}) + last_publish=now() +end +local function read() + if host() then return game end + local w=sdk.net.read(tostring(net().host_id),"match") + if type(w)~="table" or type(w.ids)~="table" or type(w.l)~="string" then received=nil;return nil end + local s={e=w.e,p=w.p,n=w.n,c=w.c,ids=w.ids,l={},r=w.r,w=w.w} + for i,id in ipairs(w.ids) do s.l[id]=tonumber(w.l:sub(i,i)) or 0 end + received=s;return s +end +local function release() + if watching then sdk.player.suspend(false);sdk.camera.watch(nil);watching=false end +end +local function controls(s) + if s and s.p=="copy" then + local token=tostring(s.e)..":"..s.n + if acknowledged~=token then + local p=sdk.player.read() + sdk.net.publish("round",{e=s.e,n=s.n,l=p.landing_seq or 0,b=p.bail_seq or 0}) + acknowledged=token + end + end + local out=s and s.p~="done" and s.p~="stopped" and (s.l[me()] or 0)<=0 + if not out then release();return end + sdk.player.suspend(true);watching=true + local target + if option("spec_cam",true) then for _,id in ipairs(s.ids) do if (s.l[id] or 0)>0 then target=id;break end end end + sdk.camera.watch(target) +end +local function menu() + local s=read();local active=s and s.p~="done" and s.p~="stopped" + local stamp=tostring(host())..tostring(active) + if stamp==menu_stamp then return end;menu_stamp=stamp + sdk.ui.menu("challenge",{title="Simon Says",section="Gamemodes",items={ + {id="start",label="Start match",enabled=host() and not active,description="Everyone races to land the called trick. First clean ride-away wins."}, + {id="stop",label="Stop match",enabled=host() and active}, + }}) +end +local function next_round() + game.n=game.n+1;game.c=draw() + if not game.c then game.p="stopped";publish();return end + game.p="countdown";game.until_at=now()+option("countdown_secs",3);game.w=0 + game.base={};game.pending={};publish() +end +local function start() + epoch=epoch+1;deck={} + local ids,have={me()},{[me()]=true} + for _,id in ipairs(sdk.net.players()) do id=tostring(id);if not have[id] then ids[#ids+1]=id;have[id]=true end end + game={e=tostring(me())..":"..epoch..":"..now(),ids=ids,l={},n=0,until_at=now()} + for _,id in ipairs(ids) do game.l[id]=option("lives",3) end + next_round() +end +local function resolve(winner) + game.w=winner or 0 + local cost=option("sudden_on",false) and game.n>=option("sudden_round",6) and option("sudden_cost",2) or 1 + local alive=0 + for i,id in ipairs(game.ids) do + if i~=winner then game.l[id]=math.max(0,game.l[id]-cost) end + if game.l[id]>0 then alive=alive+1 end + end + game.p=(alive==0 or #game.ids>1 and alive<=1) and "done" or "break" + game.until_at=now()+math.max(1,option("intermission_base",4)*option("esc_dur",0.85)^(game.n-1));publish() +end +local function advance() + if not game or game.p=="done" or game.p=="stopped" then return end + local connected={[me()]=true};for _,id in ipairs(sdk.net.players()) do connected[tostring(id)]=true end + for _,id in ipairs(game.ids) do if not connected[id] then game.l[id]=0 end end + if game.p=="countdown" and now()>=game.until_at then + game.p="copy";game.until_at=now()+math.max(5,option("round_time",45)*option("esc_dur",0.85)^(game.n-1)) + game.base={} + publish() + elseif game.p=="break" and now()>=game.until_at then next_round() + elseif game.p=="copy" then + local ride=math.max(0.5,option("ride_away",2)-option("esc_ride",0.2)*(game.n-1)) + for i,id in ipairs(game.ids) do + local p=sdk.player.skater(id);local b=game.base[id] + if not b then + local ack=sdk.net.read(id,"round") + if type(ack)=="table" and ack.e==game.e and ack.n==game.n and type(ack.l)=="number" and type(ack.b)=="number" then + b={l=ack.l,b=ack.b};game.base[id]=b + end + end + if p and b and game.l[id]>0 then + local landed,bail=p.landing_seq or 0,p.bail_seq or 0 + if bail~=b.b or p.bailing or p.on_board==false then game.pending[id]=nil end + if landed>b.l and bail==b.b and not p.bailing and (p.landed_clean==nil and p.clean~=false or p.landed_clean==true) and not (p.landed_sketchy or p.sketchy) and matches(p.landed_trick,POOL[game.c],p) then + game.pending[id]={at=now()+ride,b=bail} + end + b.l,b.b=landed,bail + local pending=game.pending[id] + if pending and now()>=pending.at and bail==pending.b and p.on_board~=false and not p.bailing then resolve(i);return end + else game.pending[id]=nil end + end + if now()>=game.until_at then resolve(nil);return end + end + if now()-last_publish>=0.25 then publish() end +end +local card_token, card_at = nil, 0 +local function card(s) + local t=s and POOL[s.c] + local token=s and tostring(s.e)..":"..s.n or "idle" + if token~=card_token then card_token,card_at=token,now() end + local accent=t and t.color or {0.23,0.79,0.98,1} + local pop=option("flash",true) and 1+math.max(0,0.15-(now()-card_at)*0.3) or 1 + local hint=t and (option("stance","regular")=="goofy" and t.ghint or t.hint) or "First clean ride-away wins the round." + local remaining=s and (s.r or math.ceil(math.max(0,s.until_at-now()))) or 0 + local items={ + {key="back",type="rect",position={0,0},size={420,220},color={0.04,0.05,0.06,0.94}}, + {key="accent",type="rect",position={0,0},size={420,6},color=accent}, + {key="title",type="text",position={16,14},size={388,24},text="SIMON SAYS"..(s and " | ROUND "..s.n or ""),font_size=16,color=accent}, + {key="call",type="text",position={16,46},size={388,48},text=t and t.name or "Ready to play",font_size=28,color={1,1,1,1}}, + {key="hint",type="text",position={16,96},size={388,38},text=hint,font_size=14,color={0.85,0.9,0.95,1}}, + {key="state",type="text",position={16,140},size={388,24},text=s and (s.p.." | "..remaining.."s | Lives: "..(s.l[me()] or 0)) or "Open Gamemodes > Simon Says",font_size=17,color=accent}, + {key="help",type="text",position={16,182},size={388,22},text="Start / Stop: Gamemodes > Simon Says",font_size=13,color={0.6,0.65,0.7,1}}, + } + sdk.ui.canvas("simon_card",{anchor="top_right",offset={24,24},size={420,220},scale=option("card_scale",1)*pop,visible=true,items=items}) +end +local function hud() + local s=read();controls(s);menu();card(s) + if not option("show_hud",true) then sdk.ui.text("simon","");return end + if not s then sdk.ui.text("simon","SIMON SAYS | Open Gamemodes > Simon Says. All players must enable this mod.");return end + local t=POOL[s.c];local lines={"SIMON SAYS | Round "..s.n.." | "..s.p} + if t then + lines[#lines+1]="Call: "..t.name.." | "..tostring(s.r or math.ceil(math.max(0,s.until_at-now()))).."s" + lines[#lines+1]=option("stance","regular")=="goofy" and t.ghint or t.hint + end + for i,id in ipairs(s.ids) do local p=sdk.player.skater(id);lines[#lines+1]=(id==me() and "You" or p and p.name or id)..": "..(s.l[id] or 0).." lives"..(s.w==i and " - round winner" or "") end + if s.p=="done" then lines[#lines+1]="Match ended. Start again from Gamemodes > Simon Says." end + sdk.ui.text("simon",table.concat(lines,"\n")) +end +return { + on_load=function() menu();hud() end, + on_fixed_update=function() if host() then advance() end;hud() end, + on_event=function(e) + if e.name=="world_changed" then release();game,received=nil,nil;publish();menu() + elseif e.name=="menu_action" and e.menu=="challenge" and host() then + if e.item=="start" and (not game or game.p=="done" or game.p=="stopped") then start() + elseif e.item=="stop" and game then game.p="stopped";publish();release() end + hud() + end + end, + on_unload=function() release();sdk.net.publish("match",nil);sdk.ui.remove_menu("challenge");sdk.ui.remove("simon_card");sdk.ui.text("simon","") end, +} diff --git a/mods/simon-says/mod.json b/mods/simon-says/mod.json new file mode 100644 index 00000000..7c696534 --- /dev/null +++ b/mods/simon-says/mod.json @@ -0,0 +1,177 @@ +{ + "id": "simon.says", + "api": 2, + "name": "Simon Says", + "version": "2.0.0", + "author": "you", + "description": "Race to copy confirmed landed tricks, with host authority, lives, ride-away checks and eliminated-player spectating.", + "entry": "main.lua", + "settings": { + "lives": { + "type": "number", + "label": "Lives", + "description": "Lives before elimination.", + "default": 3, + "min": 1, + "max": 5, + "step": 1 + }, + "round_time": { + "type": "number", + "label": "Round time (s)", + "description": "Seconds to land the called trick and ride away.", + "default": 45, + "min": 15, + "max": 120, + "step": 5 + }, + "ride_away": { + "type": "number", + "label": "Ride-away clean (s)", + "description": "Clean landing window for v1: on_board + not bailing.", + "default": 2.0, + "min": 1.0, + "max": 4.0, + "step": 0.5 + }, + "stance": { + "type": "choice", + "label": "Your stance", + "description": "Match your skater: goofy mirrors left/right flicks and hints.", + "default": "regular", + "choices": [ + "regular", + "goofy" + ] + }, + "show_hud": { + "type": "boolean", + "label": "Top-left text HUD", + "description": "Show additional top-left call and lives text. The call card remains visible.", + "default": true + }, + "countdown_secs": { + "type": "number", + "label": "Countdown (s)", + "description": "Get-ready countdown before round 1.", + "default": 3, + "min": 1, + "max": 10, + "step": 0.5 + }, + "intermission_base": { + "type": "number", + "label": "Break between rounds (s)", + "description": "Round 1 breather. Shrinks as rounds climb.", + "default": 4, + "min": 1, + "max": 8, + "step": 0.5 + }, + "sudden_on": { + "type": "boolean", + "label": "Sudden death", + "description": "Late rounds hit harder. Off keeps it chill.", + "default": false + }, + "sudden_round": { + "type": "number", + "label": "Sudden death starts round", + "description": "Fail costs double from here.", + "default": 6, + "min": 3, + "max": 12, + "step": 1 + }, + "sudden_cost": { + "type": "number", + "label": "Sudden death cost", + "description": "Lives lost per fail in sudden death.", + "default": 2, + "min": 1, + "max": 3, + "step": 1 + }, + "esc_dur": { + "type": "number", + "label": "Timer squeeze / round", + "description": "Round time multiplier each round. Lower = meaner.", + "default": 0.85, + "min": 0.7, + "max": 1, + "step": 0.01 + }, + "esc_ride": { + "type": "number", + "label": "Ride-out shrink / round (s)", + "description": "Ride-away loses this much each round.", + "default": 0.2, + "min": 0, + "max": 0.5, + "step": 0.05 + }, + "spec_cam": { + "type": "boolean", + "label": "Spectate camera", + "description": "Eliminated riders watch through a surviving player camera.", + "default": true + }, + "allow_flips": { + "type": "boolean", + "label": "Flip tricks", + "description": "Kickflips, heelflips, varials, hardflips, lasers, doubles, nollie flips.", + "default": true + }, + "allow_shuvits": { + "type": "boolean", + "label": "Shuv-its", + "description": "Pop shuvits, FS and 360 variants.", + "default": true + }, + "allow_ollies": { + "type": "boolean", + "label": "Ollies", + "description": "Ollie and nollie calls.", + "default": true + }, + "allow_grabs": { + "type": "boolean", + "label": "Grabs", + "description": "Nose, seatbelt, melon, method, mute, stale, crail, tail.", + "default": true + }, + "allow_grinds": { + "type": "boolean", + "label": "Grinds", + "description": "50-50, smith, krooked, blunts, feeble, salad, willie.", + "default": true + }, + "allow_manuals_plants": { + "type": "boolean", + "label": "Manuals and plants", + "description": "Nose manual, fastplants, handplants.", + "default": true + }, + "allow_spins": { + "type": "boolean", + "label": "Spins and combos", + "description": "Pure spins plus flip-plus-spin combos. Off removes both.", + "default": true + }, + "card_scale": { + "type": "number", + "label": "Card size", + "description": "Top-right card scale.", + "default": 1, + "min": 0.65, + "max": 1.5, + "step": 0.05 + }, + "flash": { + "type": "boolean", + "label": "Call flash", + "description": "Card pops big on each new call.", + "default": true + } + } +} diff --git a/mods/wipeout-challenge/README.md b/mods/wipeout-challenge/README.md new file mode 100644 index 00000000..36f23c8a --- /dev/null +++ b/mods/wipeout-challenge/README.md @@ -0,0 +1,21 @@ +# Wipeout Challenge 3.1 + +Requires the updated game build with `player_overlap` capability. Enable the +mod, then use **Gamemodes > Wipeout**: Start, Restart at start pad, or Stop and +return. T starts an idle run; R restarts. Each local run is a personal survival +challenge, not a shared multiplayer race or leaderboard. + +Start builds the supplied four-pad sky course 40m above the saved starting +location. Restart reuses that anchor rather than lifting another 40m. Stop and +unload restore the original position and remove all course bodies and meshes. +A map change removes the course without teleporting into the previous map. + +Hazards are persistent kinematic solids moved every fixed tick, not repeatedly +removed/respawned static boxes. Hits use enabled native board/skater collision +shapes through `sdk.physics.read(key).player_overlapping`. The first hazard +entry is forgiven; the next separate entry ends the run. Sustained contact does +not count repeatedly. One second of recovery groups simultaneous impacts. +Water sensors and falling off the course end the run immediately. Water volumes +extend below their original surface to catch fast falls. Timed mode clears after +the selected waves; survival repeats them. The obsolete safe-marker settings +were removed because they never provided a physical safe area. diff --git a/mods/wipeout-challenge/SHA256SUMS.txt b/mods/wipeout-challenge/SHA256SUMS.txt new file mode 100644 index 00000000..ea249fcc --- /dev/null +++ b/mods/wipeout-challenge/SHA256SUMS.txt @@ -0,0 +1,3 @@ +b2cf588b2b14b818d574d62f6e8c5d9f76ae256d9171871ec3b0b80b16f3b44b main.lua +c18fa0fe6f9259b7310c9dc063784ca265889a4dbfb77e95286550702eda6cc1 mod.json +e79c9177d3a4a37ae52ac23c8b95e02c7a122398706925b8de2416fb8d28f4af README.md diff --git a/mods/wipeout-challenge/main.lua b/mods/wipeout-challenge/main.lua new file mode 100644 index 00000000..29a1c7ca --- /dev/null +++ b/mods/wipeout-challenge/main.lua @@ -0,0 +1,305 @@ +local WAVE_LEN = 40 +local MAXZ = 24 +local SKY_LIFT = 40 +local FALL_MARGIN = 12 + +local RED = { 1, 0.12, 0.12 } +local WATER = { 0.1, 0.55, 1 } +local SPIKE = { 1, 0.5, 0.05 } +local DECK = { 0.55, 0.6, 0.7 } +local STARTPAD = { 0.15, 0.9, 0.35 } + +local run = { + started = false, over = false, + bail_used = false, hits = 0, + t0 = 0, + ax = 0, ay = 0, az = 0, + built = false, grace_until = 0, +} +local prev = { r = false, t = false } +local slots = {} +for i = 1, MAXZ do slots[i] = { live = false, x = 0, y = 0, z = 0, hx = 0, hy = 0, hz = 0, kind = 0 } end +local scratch = {} + +local function fmt_time(s) + if s < 0 then s = 0 end + local m = math.floor(s / 60) + local sec = math.floor(s % 60) + local ms = math.floor((s * 1000) % 1000) + return string.format("%d:%02d.%03d", m, sec, ms) +end + +local function setting(key, fallback) + local v = sdk.settings[key] + if v == nil then return fallback end + return v +end + +local function intensity_scale() + local h = setting("hazard_intensity", "hardcore") + if h == "easy" then return 0.5 end + if h == "normal" then return 0.75 end + return 1 +end + +local function wave_count() + local w = math.floor(setting("wave_count", 4)) + if w < 1 then w = 1 end + if w > 10 then w = 10 end + return w +end + +local function prand(seed) + local x = math.sin(seed * 127.1) * 43758.5 + return x - math.floor(x) +end + +local function slot_key(i) + return string.format("wz%02d", i) +end + +local function zone_color(kind) + if kind == 2 then return WATER end + if kind == 3 then return SPIKE end + return RED +end + +-- Course pieces, anchor-relative. Water keeps its original surface height +-- with depth below it so a falling skater cannot skip a thin sampled sensor. +-- kind 9 = deck platform (no hit rules). +local function course_pieces() + return { + { key = "wo.p1", x = 0, y = 0, z = 0, hx = 7, hy = 0.5, hz = 7, kind = 9, color = STARTPAD }, + { key = "wo.p2", x = 17, y = 1, z = 0, hx = 5, hy = 0.5, hz = 5, kind = 9, color = DECK }, + { key = "wo.p3", x = 17, y = 2, z = 17, hx = 5, hy = 0.5, hz = 5, kind = 9, color = DECK }, + { key = "wo.p4", x = 0, y = 3, z = 17, hx = 7, hy = 0.5, hz = 7, kind = 9, color = DECK }, + { key = "wo.w1", x = 8.5, y = -1.22, z = 0, hx = 2.5, hy = 2.0, hz = 6, kind = 2, sensor = true }, + { key = "wo.w2", x = 17, y = -0.22, z = 8.5, hx = 4, hy = 2.0, hz = 2.5, kind = 2, sensor = true }, + { key = "wo.w3", x = 8.5, y = 0.78, z = 17, hx = 2.5, hy = 2.0, hz = 6, kind = 2, sensor = true }, + } +end + +local function build_course() + local ax, ay, az = run.ax, run.ay, run.az + for _, pc in ipairs(course_pieces()) do + local desc = { + shape = { type = "box", half_extents = { pc.hx, pc.hy, pc.hz } }, + body_type = "static", + position = { ax + pc.x, ay + pc.y, az + pc.z }, + friction = 0.9, + } + if pc.sensor then desc.sensor = true end + sdk.physics.spawn(pc.key, desc) + sdk.graphics.mesh(pc.key .. "v", { + body = pc.key, + scale = { pc.hx * 2, pc.hy * 2, pc.hz * 2 }, + color = pc.color or zone_color(pc.kind), + }) + end + run.built = true +end + +local function clear_course() + for _, pc in ipairs(course_pieces()) do + sdk.physics.remove(pc.key) + sdk.graphics.remove(pc.key .. "v") + end + run.built = false +end + +local function start_pad_top() + -- Pad corner, clear of the wave-1 orbit ring. + return { run.ax - 5, run.ay + 1.6, run.az - 5 } +end + +local function teleport_to_start() + local s = start_pad_top() + sdk.player.teleport({position=s, heading=0, velocity={0,0,0}}) +end + +-- Moving hazards, anchor-relative. kind 1=red 2=water 3=spike. +local function gen_zones(wave, t, out) + local ax, ay, az = run.ax, run.ay, run.az + local n = 0 + local scale = intensity_scale() + local function add(x, y, z, hx, hy, hz, kind) + n = n + 1 + if n > MAXZ then return end + out[n] = out[n] or {} + local zn = out[n] + zn.x, zn.y, zn.z, zn.hx, zn.hy, zn.hz, zn.kind = x, y, z, hx, hy, hz, kind + end + if wave == 1 then + local count = math.floor(6 * scale + 0.5) + for i = 1, count do + local a = (i - 1) * math.pi * 2 / count + t * 0.35 + local r = 4.5 + math.sin(t * 1.5 + i) * 1 + add(ax + math.cos(a) * r, ay + 1.1, az + math.sin(a) * r, 1.0, 0.6, 1.0, 1) + end + elseif wave == 2 then + local rc = math.floor(6 * scale + 0.5) + for i = 1, rc do + local a = (i - 1) * math.pi * 2 / rc - t * 0.7 + local r = 3.2 + math.sin(t * 2 + i * 2) * 0.8 + add(ax + 17 + math.cos(a) * r, ay + 2.1, az + math.sin(a) * r, 0.9, 0.6, 0.9, 1) + end + local qc = math.floor(4 * scale + 0.5) + for i = 1, qc do + local ox = (prand(i * 31 + wave) - 0.5) * 10 + local oz = (prand(i * 57 + wave * 3) - 0.5) * 10 + if math.abs(ox) + math.abs(oz) > 4 then + add(ax + ox, ay + 1.1, az + oz, 1.0, 0.6, 1.0, 1) + end + end + elseif wave == 3 then + local sc = math.floor(8 * scale + 0.5) + for i = 1, sc do + local a = (i - 1) * math.pi * 2 / sc + t * 1.2 + local r = 3 + math.sin(t * 2.5 + i) * 1 + add(ax + 17 + math.cos(a) * r, ay + 3.4, az + 17 + math.sin(a) * r, 0.85, 0.85, 0.85, 3) + end + else + local cc = math.floor(16 * scale + 0.5) + local hubs = { { 0, 0.5, 0 }, { 17, 1.5, 0 }, { 17, 2.5, 17 }, { 0, 3.5, 17 } } + for i = 1, cc do + local hub = hubs[((i - 1) % 4) + 1] + local a = (i - 1) * math.pi * 2 / cc + t * (0.6 + (i % 3) * 0.4) + local r = 3.5 + math.sin(t * 3 + i * 1.7) * 1.5 + local kind = 1 + local h = 0.6 + if i % 5 == 0 then kind = 3 h = 0.85 end + add(ax + hub[1] + math.cos(a) * r, ay + hub[2] + h, az + hub[3] + math.sin(a) * r, + 1.0, h, 1.0, kind) + end + end + return n +end + +local function spawn_zone(i, zn) + local key = slot_key(i) + local desc = { + shape = { type = "box", half_extents = { zn.hx, zn.hy, zn.hz } }, + body_type = "kinematic", + position = { zn.x, zn.y, zn.z }, + friction = 0.7, + } + if zn.kind == 2 then desc.sensor = true end + sdk.physics.spawn(key, desc) + sdk.graphics.mesh(key .. "v", { + body = key, + scale = { zn.hx * 2, zn.hy * 2, zn.hz * 2 }, + color = zone_color(zn.kind), + }) + local s = slots[i] + s.touching = false + s.live = true + s.x, s.y, s.z, s.hx, s.hy, s.hz, s.kind = zn.x, zn.y, zn.z, zn.hx, zn.hy, zn.hz, zn.kind +end + +local function remove_zone(i) + local s = slots[i] + if not s.live then return end + local key = slot_key(i) + sdk.physics.remove(key) + sdk.graphics.remove(key .. "v") + s.live = false +end + + +local restore, phase, last_wave = nil, "idle", 0 +local function menu() + sdk.ui.menu("challenge", {title="Wipeout", section="Gamemodes", items={ + {id="start",label="Start course",enabled=phase=="idle"}, + {id="restart",label="Restart at start pad",enabled=phase~="idle"}, + {id="stop",label="Stop and return",enabled=phase~="idle"}, + }}) +end +local function clear_slots() for i=1,MAXZ do remove_zone(i) end end +local function stop(return_player) + if return_player and restore then sdk.player.teleport(restore) end + clear_slots();clear_course();restore=nil;phase="idle";run.started=false + sdk.ui.text("wo.status", "Wipeout: open Gamemodes > Wipeout to start.") + menu() +end +local function begin() + if not sdk.capabilities.player_overlap then + sdk.ui.text("wo.status","Wipeout needs the updated player-overlap API. Update the game build.");return + end + if not restore then + local p=sdk.player.read();restore={position=p.position,heading=p.heading,velocity={0,0,0}} + run.ax,run.ay,run.az=p.position[1],p.position[2]+SKY_LIFT,p.position[3] + build_course() + end + clear_slots();last_wave=0 + run.started,run.over,run.bail_used,run.hits=true,false,false,0 + run.t0=sdk.time.elapsed+2;run.grace_until=run.t0;run.hit_until=run.t0 + phase="running";teleport_to_start();menu() +end +local function finish(won,elapsed) + phase=won and "cleared" or "eliminated";run.over=true + clear_slots() + sdk.ui.text("wo.status",string.upper(phase).." at "..fmt_time(elapsed)..". Restart or Stop in Gamemodes > Wipeout.") + menu() +end +local function overlapping(key) + local body=sdk.physics.read(key) + return body and body.player_overlapping or false +end +local function fixed(ev) + local t,r=sdk.input.down("KeyT"),sdk.input.down("KeyR") + if (t and not prev.t and phase=="idle") or (r and not prev.r) then begin() end + prev.t,prev.r=t,r + if phase~="running" then return end + local now=sdk.time.elapsed + local elapsed=math.max(0,now-run.t0) + local wave=math.floor(elapsed/WAVE_LEN)+1 + local total=wave_count() + if setting("game_mode","survival")=="timed" and wave>total then finish(true,elapsed);return end + wave=(wave-1)%total+1 + if wave~=last_wave then clear_slots();last_wave=wave end + local dt=math.max(0.001,ev.dt or 1/60) + local count=gen_zones(wave,elapsed+dt,scratch) + for i=1,MAXZ do + if i<=count then + local z,s=scratch[i],slots[i] + if not s.live then spawn_zone(i,z) + else + local b=sdk.physics.read(slot_key(i)) + if b then sdk.physics.set_linvel(slot_key(i),{(z.x-b.position[1])/dt,(z.y-b.position[2])/dt,(z.z-b.position[3])/dt}) end + end + else remove_zone(i) end + end + if now>=run.grace_until then + for _,pc in ipairs(course_pieces()) do + if pc.sensor and overlapping(pc.key) then finish(false,elapsed);return end + end + local p=sdk.player.read() + if p.position[2]=run.hit_until then + run.hits=run.hits+1;run.hit_until=now+1 + if run.bail_used then finish(false,elapsed);return end + run.bail_used=true + end + s.touching=hit + end + end + local timer=setting("enable_timer",true) and (" | "..fmt_time(elapsed)) or "" + sdk.ui.text("wo.status","WIPEOUT | Wave "..wave.."/"..total..timer.." | ".. + (now Wipeout to start.") end, + on_fixed_update=fixed, + on_event=function(e) + if e.name=="world_changed" then stop(false) + elseif e.name=="menu_action" and e.menu=="challenge" then + if e.item=="start" and phase=="idle" or e.item=="restart" then begin() + elseif e.item=="stop" then stop(true) end + end + end, + on_unload=function() stop(true);sdk.ui.remove_menu("challenge");sdk.ui.text("wo.status","") end, +} diff --git a/mods/wipeout-challenge/mod.json b/mods/wipeout-challenge/mod.json new file mode 100644 index 00000000..d6d6c2b6 --- /dev/null +++ b/mods/wipeout-challenge/mod.json @@ -0,0 +1,47 @@ +{ + "id": "project-wipeout-challenge", + "api": 2, + "name": "Wipeout Challenge", + "version": "3.1.0", + "author": "opencode", + "description": "Floating platform survival with moving solid hazards, water sensors, one forgiven hit, and restart/return controls.", + "entry": "main.lua", + "settings": { + "game_mode": { + "type": "choice", + "label": "Game Mode", + "description": "Timed ends after all waves, survival loops them.", + "default": "survival", + "choices": [ + "survival", + "timed" + ] + }, + "wave_count": { + "type": "number", + "label": "Wave Count", + "description": "Number of hazard waves.", + "default": 4, + "min": 1, + "max": 10, + "step": 1 + }, + "hazard_intensity": { + "type": "choice", + "label": "Hazard Intensity", + "description": "Obstacle danger level.", + "default": "hardcore", + "choices": [ + "easy", + "normal", + "hardcore" + ] + }, + "enable_timer": { + "type": "boolean", + "label": "Enable Timer", + "description": "Show countdown timer.", + "default": true + } + } +} diff --git a/scripts/Build-Release.ps1 b/scripts/Build-Release.ps1 index ff08fb9f..5f459742 100644 --- a/scripts/Build-Release.ps1 +++ b/scripts/Build-Release.ps1 @@ -26,7 +26,7 @@ try { $symbols = Join-Path $ProjectRoot "target/release-packages/$stamp/symbols" New-Item -ItemType Directory -Path "$stage/support",$symbols -Force | Out-Null # Link this invocation directly into private staging; never copy a generic cache EXE. - & cargo rustc --release --locked --target x86_64-pc-windows-msvc --target-dir $TargetDirectory -p skate-game --bin skate3rust --no-default-features -- -C extra-filename= -o "$stage/skate3rust.exe" -C "link-arg=/PDB:$symbols/skate3rust.pdb" + & cargo rustc --release --locked --target x86_64-pc-windows-msvc --target-dir $TargetDirectory -p skate-game --bin skate3rust --no-default-features -- -C extra-filename= --emit "link=$stage/skate3rust.exe" -C "link-arg=/PDB:$symbols/skate3rust.pdb" if ($LASTEXITCODE -ne 0) { throw 'Release compilation failed' } & cargo build --release --locked --target x86_64-pc-windows-msvc --target-dir $TargetDirectory -p skate-steam-relay if ($LASTEXITCODE -ne 0) { throw 'Steam relay compilation failed' } @@ -34,9 +34,6 @@ try { -TargetDirectory $TargetDirectory ` -BinDirectory $stage ` -RelayExecutable (Join-Path $TargetDirectory 'x86_64-pc-windows-msvc/release/skate-steam-relay.exe') - # rustc also emits a dep-info file beside -o; it contains local source paths. - $depInfo = Join-Path $stage 'skate3rust.d' - if (Test-Path -LiteralPath $depInfo) { Remove-Item -LiteralPath $depInfo } New-Item -ItemType Directory -Path target/native -Force | Out-Null & rustc --edition 2024 --crate-type cdylib -C opt-level=3 -C panic=abort -C target-feature=+crt-static tools/asset_pipeline/refpack_native.rs -o target/native/refpack.dll if ($LASTEXITCODE -ne 0) { throw 'Native converter compilation failed' } diff --git a/scripts/Launch.ps1 b/scripts/Launch.ps1 index 61b0d11e..e195ed0c 100644 --- a/scripts/Launch.ps1 +++ b/scripts/Launch.ps1 @@ -40,15 +40,6 @@ Re-point assets to a valid installation, for example: $argumentList = '--assets "' + $assetsPath + '"' if (-not $Map -and $env:SKATE_TRACE_MAP) { $Map = $env:SKATE_TRACE_MAP } if (-not $TraceFile -and $env:SKATE_TRACE_FILE) { $TraceFile = $env:SKATE_TRACE_FILE } - if (-not $Map -and $Trace) { - $installRoot = Split-Path (Resolve-Path -LiteralPath $assetsPath).Path -Parent - foreach ($candidate in @( - (Join-Path $installRoot 'maps\DownTown.skate'), - (Join-Path $assetsPath 'private\native-backdrops\DownTown.skate') - )) { - if (Test-Path -LiteralPath $candidate) { $Map = $candidate; break } - } - } if ($Map) { $mapPath = (Resolve-Path -LiteralPath $Map).Path if ([System.IO.Path]::GetExtension($mapPath) -ine '.skate') { throw "Not a .skate map file: $mapPath" } @@ -62,21 +53,14 @@ Re-point assets to a valid installation, for example: Join-Path $ProjectRoot 'trace-downtown-lag.json' } if (Test-Path -LiteralPath $tracePath) { Remove-Item -LiteralPath $tracePath -Force } - $argumentList += ' --trace "' + $tracePath + '" --trace-wait --trace-gpu' + $argumentList += ' --trace "' + $tracePath + '" --trace-wait --trace-seconds 10' Write-Host "Trace: $tracePath" - Write-Host 'F9 = start recording at lag spot, F10 = stop and export (open in https://ui.perfetto.dev)' + Write-Host 'F9 starts a 10-second CPU capture; export stops automatically. Open the JSON in Perfetto.' } # Dev packages live in repo mods/; the exe otherwise only looks beside bin/. $env:SKATE3_MODS = Join-Path $ProjectRoot 'mods' New-Item -ItemType Directory -Path $env:SKATE3_MODS -Force | Out-Null Write-Host "Mods: $env:SKATE3_MODS" - if ($Trace) { - # Use cmd start so the game gets a real window; PS Start-Process argument quoting is flaky. - $cmd = 'start "Skate3 Rust" /D "' + $ProjectRoot + '" "' + $executable + '" ' + $argumentList - Start-Process -FilePath 'cmd.exe' -ArgumentList '/c', $cmd -WorkingDirectory $ProjectRoot - Write-Host 'Game launched in its own window. Close it when you are done.' - return - } $game = Start-Process -FilePath $executable -WorkingDirectory $ProjectRoot ` -ArgumentList $argumentList -NoNewWindow -Wait -PassThru ` -RedirectStandardOutput $log -RedirectStandardError $errorLog diff --git a/scripts/build-multiplayer-test.ps1 b/scripts/build-multiplayer-test.ps1 index e5e30292..ced6a720 100644 --- a/scripts/build-multiplayer-test.ps1 +++ b/scripts/build-multiplayer-test.ps1 @@ -8,9 +8,9 @@ $build = Join-Path $output 'build' New-Item -ItemType Directory -Path $relay,$build -Force | Out-Null $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static' # Share dependency artifacts, but link directly into this checkout's staging folder. -& cargo rustc --release --target x86_64-pc-windows-msvc --target-dir (Join-Path $workspace 'target') -p skate-game --bin skate3-multiplayer --no-default-features --locked -- -C extra-filename=-multiplayer-test -o (Join-Path $build 'skate3.exe') +& cargo rustc --release --target x86_64-pc-windows-msvc --target-dir (Join-Path $workspace 'target') -p skate-game --bin skate3-multiplayer --no-default-features --locked -- -C extra-filename=-multiplayer-test --emit "link=$build/skate3-multiplayer-test.exe" if ($LASTEXITCODE -ne 0) { throw 'Multiplayer build failed.' } -& cargo rustc --release --target x86_64-pc-windows-msvc --target-dir (Join-Path $workspace 'target') -p skate-steam-relay --bin skate-steam-relay --locked -- -C extra-filename=-multiplayer-test -o (Join-Path $build 'relay.exe') +& cargo rustc --release --target x86_64-pc-windows-msvc --target-dir (Join-Path $workspace 'target') -p skate-steam-relay --bin skate-steam-relay --locked -- -C extra-filename=-multiplayer-test --emit "link=$build/relay-multiplayer-test.exe" if ($LASTEXITCODE -ne 0) { throw 'Steam relay build failed.' } Copy-Item -LiteralPath (Join-Path $build 'skate3-multiplayer-test.exe') -Destination (Join-Path $output 'skate3-multiplayer.exe') -Force Copy-Item -LiteralPath (Join-Path $build 'relay-multiplayer-test.exe') -Destination (Join-Path $relay 'skate-steam-relay.exe') -Force diff --git a/sdk/ENGINE_API.md b/sdk/ENGINE_API.md index af655ab8..762dfeea 100644 --- a/sdk/ENGINE_API.md +++ b/sdk/ENGINE_API.md @@ -209,3 +209,54 @@ engine imposes no game rules. Omit `section` to use the Mods menu. Menu removal mod unload removes its entry; empty sections disappear. Back returns to the section that opened the menu. Limits: 8 custom sections, 64 section menus total, 32 bytes per section name. `sdk.capabilities.menus >= 3` supports this placement. + +## Local actor suspension and peer cameras + +`sdk.player.suspend(true)` freezes the local native simulation, hides its skater, +and removes its multiplayer collision participation. `false` releases only the +calling mod's suspension. Mod retirement, errors, and world changes release its +ownership. Multiple mods may suspend independently. Available with +`sdk.capabilities.player_control >= 1`; skater observations expose `suspended`. + +`sdk.camera.watch(peer)` mirrors the connected player's transmitted gameplay +camera transform and vertical FOV (`sdk.capabilities.camera >= 3`). Network +latency still applies. Older peers without camera packets use the body-follow +fallback. `nil` restores the native camera. This does not transfer player input. + +`sdk.net.info().host_id` is the authenticated host **player** ID from the roster, +not a Steam ID or socket endpoint. It is `"0"` until the host roster arrives. +Use this player ID with `sdk.net.read`, `sdk.player.skater`, and camera watching. + +The Game of SKATE example keeps its turn roster and saved position in an `origin` +record and its compact changing state in `skate`. It waits for every participant +to acknowledge suspension, then resets the active player using +`sdk.player.teleport`. Only that player's post-reset landing/bail counters count. +Late joiners wait for the next match; a missing enabled mod aborts preparation +with an actionable status. Stop/unload returns participants to their pre-match +positions and restores their cameras. Enable the same package version everywhere. + +### Native player overlap (capability `player_overlap = 1`) + +`sdk.physics.read(key).player_overlapping` reports whether the enabled native +board/skater collision shapes intersect the mod-owned body. Sensors require +intersection; solids include the native solver's 0.025m contact margin. This is +an explicit geometric query independent of collision masks, updated before each +fixed callback. Suspended/attached players report false. No duplicate Rapier +player is simulated. Detect transitions in Lua to count entries rather than +counting every frame of sustained contact. It is a sampled query, not a swept +trigger: use suitably thick sensor volumes for fast-moving actors. + +Native foot-support, air-trajectory and camera queries include mod-created +solids, preserving nearest-hit ordering and native map behavior. + +### Settled scoring details (`landed_details = 1`) + +Player observations include `landed_trick_base` (engine-authored, unlocalized +base label), `landed_spin_degrees` (signed body rotation, excluding board +rotation), `landed_clean` and `landed_sketchy`. These are captured together when +`landing_seq` advances and replicated with that observation. They describe the +last settled trick; compare sequence numbers to distinguish it from old results. +Game rules and label aliases remain in Lua. The existing localized +`landed_trick` remains available for display. + +See [GENERAL_API.md](GENERAL_API.md) for native bodies, joint and part overrides, contact lifecycle, input overrides, graph access and acknowledged commands. diff --git a/sdk/GENERAL_API.md b/sdk/GENERAL_API.md new file mode 100644 index 00000000..d9a23414 --- /dev/null +++ b/sdk/GENERAL_API.md @@ -0,0 +1,210 @@ +# General engine API + +Manifest API remains `2`. Feature discovery uses compiled `sdk.capabilities`: +`engine_access=1`, `command_results=1`, `native_bodies=1`, +`input_override=1`, `player_physics=2`, `player_overlap=1`, `landed_details=1`. +The host has no injury, vehicle or challenge rules. Those live in Lua. + +## Reading systems + +`sdk.engine.systems()` lists available system names. `sdk.engine.read(name)` +reads the latest snapshot, without executing a new simulation step: + +| Name | Data | Write interface | +| --- | --- | --- | +| player | Mode, bail, stance, pose, velocity, scoring and transition counters | Existing player teleport, participation, attachment and gesture controls | +| rig | Native bodies, anatomical joints, solver contacts and joint loads | `sdk.rig`, typed `sdk.bodies` impulses | +| bodies | This mod's Rapier bodies and contacts | `sdk.physics` creation, geometry, forces, velocities, transforms, joints and motors | +| input | Raw pad, keys and 18 mapped action values | `sdk.input.override_action` | +| graphs | Live action/motion controller states, state times and active behavior IDs | `sdk.graphs.set_enabled` | +| animation | Tick, pose generation and bone count | No arbitrary animation replacement or pose injection | +| scoring | Current player scoring observations | No arbitrary native score/collector mutation | +| world | Current map observation | Existing mod geometry and volume APIs; no rewriting map assets | +| camera | Current camera observation | Existing follow, watch, mirror, rig, set, capture and release controls | +| network | Session identity, peers and mod state | Existing state publication, entity replication and authority-controlled operations | +| commands | This mod's most recent command receipts | `sdk.commands.request` | + +The existing graphics, audio, UI, settings, assets and timer interfaces remain +available. Menus can create a named pause-menu section and nested pages using +`sdk.ui.menu(key, {section=..., title=..., items=...})`. + +Catalogs are larger and requested explicitly: + +```lua +sdk.engine.inspect("catalog", "graphs") -- or "scoring" +-- On a later callback: +local result = sdk.commands.result("catalog") +if result and result.ok then + local action = result.value.action +end +``` + +Graph catalogs expose authored names, hierarchy, transitions, operations and +runtime behavior IDs. `states[].enabled`, `transitions[].enabled` and +`behaviors[].enabled` reflect runtime gates. `authored_enabled` and operation +metadata describe the loaded asset. Binding `states[].behaviors` uses operation +IDs; use `behaviors[].id` for behavior gates. Scoring catalogs expose loaded +definition IDs, labels, categories and authored points, not a substitute scoring +implementation. + +## Commands and results + +```lua +local token = sdk.commands.request("release_joint", { + kind="player_joint", joint=joint.index, + options={free_swing=true, free_twist=true, drive_enabled=false, + possession_enabled=false, descendants=true} +}) +-- A later snapshot, after the host executes the command: +local result = sdk.commands.result("release_joint") +if result then + if result.ok then sdk.log("Applied") else sdk.log(result.error) end +end +``` + +A request wraps any validated native command table. The `kind` and payload are +the same as the public wrappers in `crates/skate-mods/src/api.lua`; the full +schema is `Command` in `crates/skate-mods/src/vm.rs`. Results contain `token`, +`ok`, `error`, `value`, and simulation `tick`. Reusing a key replaces its slot; +the Lua accessor ignores an older token. Maximum 64 result keys per mod. +Receipts remain readable until replacement or mod/world cleanup. Nested requests +are rejected. Invalid Lua/schema arguments still fail callback validation. + +`ok` means the host accepted and applied that command. It does not mean a remote +peer acknowledged a network message or a multi-tick operation finished. Host +execution failures become receipts instead of disabling the mod. Commands are +not rollback transactions. Ordinary wrappers remain fire-and-forget, with their +existing error behavior. Skyline uses a receipt for chassis creation before +binding graphics, audio and driving state to that body. + +## Native bodies and constraints + +All native IDs are **zero-based**; Lua arrays are one-based. Discover IDs from +observations instead of assuming animation bone IDs equal physical part IDs. + +Use `sdk.rig.read({"tick", "ragdoll", "contacts"})` to request only those +top-level fields. `sdk.rig.contacts()`, `parts()` and `joints()` also convert only +their requested section into Lua. Calling `sdk.rig.read()` without arguments +still returns the complete rig, including joint solver data. + +```lua +local rig = sdk.rig.read() +local part = rig.parts[1] +local body = sdk.bodies.read({kind="skater", index=part.index}) +sdk.bodies.impulse(body, {0,10,0}, body.position) +sdk.bodies.angular_impulse({kind="board", index=0}, {0,1,0}) +sdk.bodies.impulse({kind="mod", key="crate"}, {0,10,0}) +``` + +Native skater/board reads expose position, XYZW quaternion, world linear/angular +velocities, inverse mass/inertia and state flags. Skater parts also expose the +mapped bone name, joint index, effective collision/material/drive settings and +the direct part override. Impulse vectors use world coordinates: N s for linear, +N m s for angular; an optional world point adds the corresponding torque. Native +impulses are local-player only and rejected while attached or suspended. Commands +apply between simulation steps. Subsequent native controls and constraints still +participate. Bodies with no dynamic mass reject impulses. + +`sdk.rig.configure_joint(index, options)` replaces a mod-owned override: + +- `swing_limit`, `twist_limit`: radians in `[0.01, pi]`. +- `free_swing`, `free_twist`: release the corresponding angular limit. +- `enabled=false`: omit the entire joint constraint, including its linear anchor. +- `drive_enabled=false`: omit the child's animation bone drives. +- `possession_enabled=false`: omit board-holding drives involving the child. +- `descendants=true`: extend those **drive suppressions** down the physical + joint hierarchy. Joint angle/enable changes still affect the specified joint. + +Free angular limits retain linear attachment. This is what Broken Bones uses. +Native parameters and packed joint frames are readable but are not writable raw +memory. Limits apply to a temporary joint copy, so native defaults remain intact. +Drive-enable observations include ancestor and part suppression; `true` permits +native drives but does not create a drive that the current native state omits. + +`sdk.rig.configure_part(index, options)` replaces a physical part override: +`motion="dynamic"|"frozen"|"static"` (native flags 4/2/1), `collision=bool`, +`friction=0..10` (both material coefficients), `animation_drives=bool`, and +`possession_drives=bool`. These affect the native solve. They do not replace the +whole player's movement state, mass preset, rendering or animation output. +Freezing a physical part does not freeze its animation target. + +`reset_joint(index)` and `reset_part(index)` release owned overrides. +`sdk.rig.reset()` releases **all** this mod's joint and part overrides. Direct +joint/part slots, action slots and graph gates reject a different owner's write. +Omitted fields follow native behavior. Mod failure, disable/unload and world +changes restore owned controls. Released impulses are physical changes and are +not undone. Another mod's ancestor drive suppression can still affect a child. + +## Contacts and joint loads + +`sdk.rig.contacts()` reads the most recent completed native solve. `rig.tick` +is the cursor; do not count the same snapshot again in multiple callbacks. +Contacts include: + +- `id`, `phase`: stable body-pair identity and `begin`, `stay`, `end`. + Multiple manifold points share the pair lifecycle. +- `a`, `b`: board/skater body index, static world, external solid or remote actor. + External solids include `solid_id` and, when locally known, mod `owner`/`key`. + Remote actors include peer ID and board/skater part identity. +- World point and normal; normal/friction/total forces; impulse (`force * dt`); + combined static/dynamic friction and material tags. +- `relative_velocity_before_solve`: A minus B velocity at the contact point, + including angular velocity, sampled from the bodies before this solve. + `closing_speed=max(0,-dot(relative_velocity,normal))`. + +These are solver samples, not exact continuous time-of-impact measurements. +Resting support can have substantial force, so injury rules should also require +closing speed. `end` reports have zero force/impulse/velocity. At most 128 active +contact reports plus enough end reports to reach 256 are published per tick; +`contacts_truncated` signals omitted reports. Pair tracking still examines all +contacts; truncation does not create false contact ends. + +Each joint's `load` is absent if no eligible row was solved. Otherwise it exposes +world linear/angular impulse and force/torque, plus the packed `solver_words`. +The host projects native accumulators (bytes 32/48) using axes at 192/240 and +divides displacement-producing corrections by dt for impulses, then dt again for +forces. Signs are for the child/A body. Angular values describe the direct +angular constraint couple, excluding the lever-arm torque from the linear anchor. +These are constraint reactions, not a bone-damage model. + +Rapier `sdk.physics.read(key).player_overlapping` tests enabled native player +shapes against the body's actual collider shapes. Sensors use intersection; +solids include a 0.025 m contact tolerance. Suspended/attached players are excluded. +This differs from `sdk.volumes`, which checks observed player root positions. + +## Input and graph control + +`sdk.input.override_action(id, value)` overrides mapped gameplay actions 64–81 +with a value in `[-1,1]`; nil releases the slot. It applies at the next input +publication and lasts until released or cleaned up. It leaves raw pad/key +observations intact and respects menu/debug input suppression. + +`sdk.graphs.read("action"|"motion")` reads the live controller. +`sdk.graphs.set_enabled(graph, "state"|"transition"|"behavior", id, bool|nil)` +sets or restores an eligibility gate. It does not force a state jump, bypass +native conditions, inject callbacks, or guarantee an immediate exit from an +already-active state. Use `commands.request` with `kind="graph_gate"` when the +mod needs an explicit rejection result. Native graph execution still owns state +entry/exit and behavior lifecycle. + +## Bundled examples and remaining boundaries + +Broken Bones selects physical limb joints using observed names/topology and +requires both closing speed and impulse. It releases angular limits and limb +drives, keeps linear anchors, reports success only after a receipt, and provides +a Heal action. Injury state is published through generic mod network state; the +existing native pose stream carries the resulting physical pose. Peers must run +the mod to display injury summaries. It does not simulate fractures medically, +detach meshes, or add blood rendering. + +Simon Says uses confirmed settlement metadata (`landed_trick_base`, +`landed_spin_degrees`, `landed_clean`, `landed_sketchy`) and explicit round +baselines. Wipeout uses persistent kinematic solids and actual collider overlaps. + +The API now provides these concrete lower-level interfaces, not arbitrary access +to every Rust resource. Arbitrary animation blending/pose injection, scoring +collector replacement, raw graph operation replacement, native topology creation, +and direct mutation of another peer's native joints remain unexposed. New needs +should extend these engine interfaces rather than adding mod-specific functions. + +Contact lifecycle refers to force-bearing native solver reports: an end means no positive normal response was reported for that pair. It is not proof that the collider shapes have geometrically separated. diff --git a/sdk/examples/broken-bones/README.md b/sdk/examples/broken-bones/README.md new file mode 100644 index 00000000..ce2cab7c --- /dev/null +++ b/sdk/examples/broken-bones/README.md @@ -0,0 +1,25 @@ +# Broken Bones + +Enable **Broken Bones** in Mods on the matching generalized-API build. A hard +limb impact releases that joint's angular limits and the limb's animation and +board-holding drives. The limb stays attached at its linear joint anchor. + +Use **Skater mods > Broken bones > Heal all injuries** to restore this mod's +overrides. Turning off Enable injuries also heals. Map changes/unload restore +controls. Heal on teleport is enabled by default. Normal bail recovery alone +does not heal injuries. + +Settings expose impulse threshold (default 80 N s), minimum closing speed +(3 m/s), cooldown, bail-only detection and HUD visibility. Thresholds are gameplay +settings, not medical fracture estimates. Resting support force alone cannot +cause an injury. Joint names and indices come from the loaded native rig. + +All injury logic is in `main.lua`. The host only exposes body/contact observations, +joint and drive controls, menu actions, networking and command results. A failed +override is reported instead of falsely marking a joint injured. Owned overrides +are restored without clearing another mod's direct joint settings. + +With the mod enabled on peers, the HUD displays remote injury counts; native +physical poses use the existing multiplayer pose stream. There are no detached +limb meshes or blood effects. Headless Lua/native integration tests cover the +rules and constraint controls; injury feel still needs gameplay evaluation. diff --git a/sdk/examples/broken-bones/SHA256SUMS.txt b/sdk/examples/broken-bones/SHA256SUMS.txt new file mode 100644 index 00000000..4ede2391 --- /dev/null +++ b/sdk/examples/broken-bones/SHA256SUMS.txt @@ -0,0 +1,3 @@ +5d30691ecb4b3865c462680685a3c146fe7fb6e8fd0b41d5e951fb93fe6a20ae main.lua +1461c828f1fad08bb6f7f96ddde56368af499caca6deb7a709d3ad34bdcf8378 mod.json +6b2ae2e24202b97ca88173cca3412e0c6ba1245fbf2896067e98bbdf2d523599 README.md diff --git a/sdk/examples/broken-bones/main.lua b/sdk/examples/broken-bones/main.lua new file mode 100644 index 00000000..2d0ddd28 --- /dev/null +++ b/sdk/examples/broken-bones/main.lua @@ -0,0 +1,94 @@ +-- Injury decisions belong to Lua; the engine exposes generic bodies/constraints. +local broken, pending, last_hit, recent = {}, {}, {}, {} +local last_tick, last_mode, was_enabled, status = nil, nil, nil, "" +local function cfg(k,d) local v=sdk.settings[k];if v==nil then return d end;return v end +local function length(v) v=v or {};return math.sqrt((v[1] or 0)^2+(v[2] or 0)^2+(v[3] or 0)^2) end +local function publish() + local ids={};for i in pairs(broken) do ids[#ids+1]=i end;table.sort(ids) + sdk.net.publish("injuries",{v=1,j=ids}) +end +local function heal() + for i in pairs(broken) do sdk.rig.reset_joint(i) end + for i in pairs(pending) do sdk.rig.reset_joint(i) end + broken,pending,recent,last_hit={},{},{},{};status="Joints restored";publish() +end +local function menu() + sdk.ui.menu("bones",{title="Broken bones",section="Skater mods",items={ + {id="heal",label="Heal all injuries",description="Restore this mod's joint and drive overrides."}, + }}) +end +local function eligible(j) + return j and (j.name:find("LEFT",1,true) or j.name:find("RIGHT",1,true)) +end +local function draw() + if not cfg("show_hud",true) then sdk.ui.text("bones","");return end + local labels={};for _,j in pairs(broken) do labels[#labels+1]=j.name:gsub("JOINT_",""):gsub("_"," ") end;table.sort(labels) + local text="BROKEN BONES | "..(#labels==0 and "No injuries" or table.concat(labels,", ")) + for _,id in ipairs(sdk.net.players()) do + if tostring(id)~=tostring(sdk.net.info().local_id) then + local p=sdk.net.read(id,"injuries") + if type(p)=="table" and p.v==1 and type(p.j)=="table" and #p.j>0 then + local skater=sdk.player.skater(id);text=text.."\n"..(skater and skater.name or tostring(id))..": "..#p.j.." injured joints" + end + end + end + if status~="" then text=text.."\n"..status end + sdk.ui.text("bones",text:sub(1,1000)) +end +local function fixed() + local rig=sdk.rig.read({"tick","ragdoll","contacts"});local player=sdk.player.read();local now=sdk.time.elapsed + if cfg("heal_on_teleport",true) and player.mode=="teleport" and last_mode~="teleport" then heal() end + last_mode=player.mode + local enabled=cfg("enabled",true) + if was_enabled and not enabled then heal() end;was_enabled=enabled + for i,j in pairs(pending) do + local result=sdk.commands.result("joint_"..i) + if result then + pending[i]=nil + if result.ok then broken[i]=j;status="Impact injured "..j.name:gsub("JOINT_",""):gsub("_"," ");publish() + else status="Joint unchanged: "..tostring(result.error) end + end + end + if not enabled or last_tick==rig.tick or sdk.player.attached() or player.suspended then draw();return end + last_tick=rig.tick + local joints + local strongest={} + for _,c in ipairs(rig.contacts or {}) do + if c.phase~="end" and (c.closing_speed or 0)>=cfg("closing_speed",3) then + if not joints then + joints={};for _,j in ipairs(sdk.rig.joints()) do joints[j.child]=j end + end + for _,body in ipairs({c.a,c.b}) do + if body and body.kind=="skater" then + local j=joints[body.index];local impulse=length(c.impulse) + if eligible(j) and impulse>=cfg("impact_impulse",80) and not broken[j.index] and not pending[j.index] and + (not j.override_owner or j.override_owner==sdk.mod_id) and now-(last_hit[j.index] or -100)>=cfg("cooldown",0.75) then + if not strongest[j.index] or impulse>strongest[j.index].impulse then strongest[j.index]={joint=j,impulse=impulse,at=now} end + end + end + end + end + end + for i,hit in pairs(strongest) do recent[i]=hit end + for i,hit in pairs(recent) do + if now-hit.at>0.3 then recent[i]=nil + elseif not cfg("only_bails",false) or player.bailing or rig.ragdoll then + sdk.commands.request("joint_"..i,{kind="player_joint",joint=i,options={free_swing=true,free_twist=true, + drive_enabled=false,possession_enabled=false,descendants=true}}) + pending[i]=hit.joint;last_hit[i]=now;recent[i]=nil + end + end + draw() +end +return { + on_load=function() + if (sdk.capabilities.player_physics or 0)<2 or not sdk.capabilities.command_results then error("Broken Bones requires the generalized native rig API build") end + menu();publish();draw() + end, + on_fixed_update=fixed, + on_event=function(e) + if e.name=="world_changed" then broken,pending,recent,last_hit={},{},{},{};last_tick=nil;status="";publish();menu() + elseif e.name=="menu_action" and e.menu=="bones" and e.item=="heal" then heal();draw() end + end, + on_unload=function() heal();sdk.net.publish("injuries",nil);sdk.ui.remove_menu("bones");sdk.ui.text("bones","") end, +} diff --git a/sdk/examples/broken-bones/mod.json b/sdk/examples/broken-bones/mod.json new file mode 100644 index 00000000..2aa7c4b9 --- /dev/null +++ b/sdk/examples/broken-bones/mod.json @@ -0,0 +1,62 @@ +{ + "id": "examples.broken-bones", + "name": "Broken Bones", + "version": "1.0.0", + "api": 2, + "author": "Skate3clone", + "description": "Impact-based limb injuries using native contacts, joint topology and reversible constraint overrides. All damage rules run in Lua.", + "entry": "main.lua", + "settings": { + "impact_impulse": { + "type": "number", + "label": "Impact impulse threshold", + "description": "Gameplay threshold in N s; tune for your preferred injury frequency.", + "default": 80, + "min": 5, + "max": 1000, + "step": 5 + }, + "closing_speed": { + "type": "number", + "label": "Minimum impact speed", + "description": "Minimum pre-solve closing speed in metres/second; excludes resting support.", + "default": 3, + "min": 0.5, + "max": 20, + "step": 0.5 + }, + "cooldown": { + "type": "number", + "label": "Impact cooldown", + "description": "Seconds between counted impacts on the same joint.", + "default": 0.75, + "min": 0.1, + "max": 5, + "step": 0.05 + }, + "enabled": { + "type": "boolean", + "label": "Enable injuries", + "description": "Process native impacts while enabled.", + "default": true + }, + "only_bails": { + "type": "boolean", + "label": "Only injure during bails", + "description": "Retain recent impacts briefly to catch the transition into a bail.", + "default": false + }, + "show_hud": { + "type": "boolean", + "label": "Show injuries", + "description": "Display local and remote injury summaries.", + "default": true + }, + "heal_on_teleport": { + "type": "boolean", + "label": "Heal on teleport", + "description": "Restore joints when a teleport/reset state is observed.", + "default": true + } + } +} diff --git a/sdk/examples/game-of-skate/main.lua b/sdk/examples/game-of-skate/main.lua index 25be2e6f..3833f2b4 100644 --- a/sdk/examples/game-of-skate/main.lua +++ b/sdk/examples/game-of-skate/main.lua @@ -1,233 +1,224 @@ --- Game rules belong in Lua. The engine only publishes generic scoring events. +-- All match rules and turn authority live here; the SDK is game-mode agnostic. local WORD = "SKATE" -local seen, game = {}, nil -local last_publish = -1 -local running = false -local menu_stamp - +local game, running, serial = nil, false, 0 +local received, received_host +local applied, released, ack, restore = nil, nil, nil, nil +local armed_at, last_publish, menu_stamp = 0, -1, nil local function info() return sdk.net.info() end local function me() return tostring(info().local_id or "0") end +local function host() return not info().active or info().is_host end local function roster() - local ids, have = {}, {} - for _, id in ipairs(sdk.net.players()) do - id = tostring(id) - if not have[id] then ids[#ids + 1], have[id] = id, true end + local ids, have = {me()}, {[me()]=true} + for _, peer in ipairs(sdk.net.players()) do + local id=tostring(peer) + if not have[id] then ids[#ids+1],have[id]=id,true end end - if not have[me()] then ids[#ids + 1] = me() end table.sort(ids) + -- The transport host starts, regardless of numeric/lexicographic peer order. + local first=host() and me() or tostring(info().host_id) + for i,id in ipairs(ids) do if id==first then table.remove(ids,i);table.insert(ids,1,id);break end end return ids end local function name(id) - if id == me() then return "You" end - local s = sdk.player.skater(id) - return s and s.name or ("Player " .. tostring(id)) + if id==me() then return "You" end + local s=sdk.player.skater(id) + return s and s.name or ("Player "..tostring(id)) end local function canon(s) return tostring(s or ""):lower():gsub("[^%w]", "") end -local function allowed(s) - local c = canon(s) - return c ~= "" and (sdk.settings.allow_ollie or c ~= "ollie" and c ~= "nollie") -end -local function alive(s) - local ids = {} - for _, id in ipairs(s.ids) do - if (s.l[id] or 0) < 5 then ids[#ids + 1] = id end - end - return ids -end +local function allowed(t) local c=canon(t);return c~="" and (sdk.settings.allow_ollie or c~="ollie" and c~="nollie") end +local function index(ids,id) for i,p in ipairs(ids) do if p==id then return i end end return 0 end +local function count(s) local n=0;for _,id in ipairs(s.ids) do if (s.l[id] or 0)<5 then n=n+1 end end return n end local function next_setter(s) - local ids = alive(s) - for i, id in ipairs(ids) do if id == s.a then return ids[i % #ids + 1] end end - return ids[1] or s.ids[1] + local at=index(s.ids,s.a) + for step=1,#s.ids do local id=s.ids[(at+step-1)%#s.ids+1];if (s.l[id] or 0)<5 then return id end end + return s.ids[1] end -local function copying(s, id) - return #s.ids == 1 or id ~= s.a -end - --- Sample everybody every tick, including spectators/setters. Joining or loading --- establishes a baseline; old results can never satisfy a new turn. -local function events(ids) - local out, present = {}, {} - local skaters = sdk.player.skaters() - for _, id in ipairs(ids) do - present[id] = true - local s = skaters[id] - if s then - local landing, bail = tonumber(s.landing_seq) or 0, tonumber(s.bail_seq) or 0 - local previous = seen[id] - if previous then - if bail > previous.bail then out[id] = {bail=true} - elseif landing > previous.landing and not s.bailing then - local trick = tostring(s.landed_trick or "") - if trick ~= "" then out[id] = {trick=trick} end - end - end - seen[id] = {landing=landing, bail=bail} - end +local function next_copier(s) + for _,id in ipairs(s.ids) do + if (s.solo or id~=s.a) and (s.l[id] or 0)<5 and not s.d[id] and not s.f[id] then return id end end - for id in pairs(seen) do if not present[id] then seen[id] = nil end end - return out end - --- Compact per-roster strings keep ten-player state below the 512-byte Lua limit. local function publish(s) - local letters, done, failed = {}, {}, {} - for _, id in ipairs(s.ids) do - letters[#letters+1] = tostring(s.l[id] or 0) - done[#done+1] = s.d[id] and "1" or "0" - failed[#failed+1] = s.f[id] and "1" or "0" + local l,d,f={},{},{} + for _,id in ipairs(s.ids) do + l[#l+1]=tostring(s.l[id] or 0);d[#d+1]=s.d[id] and "1" or "0";f[#f+1]=s.f[id] and "1" or "0" end - sdk.net.publish("skate", {p=s.p, n=s.n, a=s.a, t=s.t, w=s.w, - r=math.ceil(math.max(0, s.u-sdk.time.elapsed)), ids=s.ids, - l=table.concat(letters), d=table.concat(done), f=table.concat(failed)}) - last_publish = sdk.time.elapsed + -- Split immutable roster/start pose from turn state; each value stays <512B. + sdk.net.publish("origin",{e=s.e,v=s.v,ids=s.ids,p=s.origin.position,h=s.origin.heading}) + sdk.net.publish("skate",{e=s.e,v=s.v,q=s.q,p=s.p,n=s.n,a=index(s.ids,s.a),c=index(s.ids,s.c), + t=s.t,w=index(s.ids,s.w),g=s.g,r=math.ceil(math.max(0,(s.u or 0)-sdk.time.elapsed)), + l=table.concat(l),d=table.concat(d),f=table.concat(f),error=s.error}) + last_publish=sdk.time.elapsed end local function read_game() - if info().is_host or not info().active then return game end - local wire = sdk.net.read(tostring(info().host_id), "skate") - if type(wire) ~= "table" or type(wire.ids) ~= "table" or type(wire.l) ~= "string" then return nil end - local s = {p=wire.p, n=wire.n, a=wire.a, t=wire.t, w=wire.w, r=wire.r, ids=wire.ids, l={}, d={}, f={}} - for i, id in ipairs(s.ids) do - s.l[id] = tonumber(wire.l:sub(i,i)) or 0 - s.d[id] = tostring(wire.d):sub(i,i) == "1" - s.f[id] = tostring(wire.f):sub(i,i) == "1" - end + if host() then return game end + local peer=tostring(info().host_id) + if received_host~=peer then received,received_host=nil,peer end + local w,o=sdk.net.read(peer,"skate"),sdk.net.read(peer,"origin") + if type(w)~="table" or type(o)~="table" then received=nil;return nil end + if w.e~=o.e or w.v~=o.v then return received end + if type(o.ids)~="table" or type(w.l)~="string" then return nil end + local s={e=w.e,q=w.q,p=w.p,n=w.n,ids=o.ids,a=o.ids[w.a],c=o.ids[w.c],t=w.t,w=o.ids[w.w],g=w.g,r=w.r,error=w.error, + origin={position=o.p,heading=o.h,velocity={0,0,0}},l={},d={},f={}} + for i,id in ipairs(s.ids) do s.l[id]=tonumber(w.l:sub(i,i)) or 0;s.d[id]=tostring(w.d):sub(i,i)=="1";s.f[id]=tostring(w.f):sub(i,i)=="1" end + received=s return s end -local function begin_set(s, setter) - s.p, s.a, s.t, s.d, s.f = "s", setter, "", {}, {} - s.u = sdk.time.elapsed + 90 +local function release() + if applied then + sdk.player.suspend(false);sdk.camera.watch(nil) + if restore then sdk.player.teleport(restore) end + sdk.net.publish("ack",nil) + end + applied,released,ack,restore=nil,nil,nil,nil end -local function fail(s, id) - if not s.d[id] and not s.f[id] then - s.f[id], s.l[id] = true, math.min(5, (s.l[id] or 0) + 1) +local function control(s, fixed) + if not s or s.p=="i" or s.p=="w" then release();return end + local token=tostring(s.e)..":"..tostring(s.q) + if applied~=token then + if not restore then local p=sdk.player.read();restore={position=p.position,heading=p.heading,velocity={0,0,0}} end + applied,released,ack=token,nil,nil + sdk.player.suspend(true) + sdk.camera.watch(s.c~=me() and s.c or nil) + end + if fixed and not ack then + -- Acknowledge the suspension on the next command frame. + ack={e=s.e,q=s.q,stage=0} + sdk.net.publish("ack",ack) + end + if s.g and s.c==me() and released~=token then + sdk.player.teleport(s.origin) + sdk.player.suspend(false) + sdk.camera.watch(nil) + released,armed_at=token,sdk.time.elapsed+0.35 + end + if fixed and released==token and ack and ack.stage==0 and sdk.time.elapsed>=armed_at then + local p=sdk.player.read() + ack={e=s.e,q=s.q,stage=1,l=p.landing_seq or 0,b=p.bail_seq or 0} + sdk.net.publish("ack",ack) end end -local function stopped(ids) - return {p="i",n=0,a=ids[1],t="",w="",ids=ids,l={},d={},f={},u=sdk.time.elapsed} +local function turn(s,id,phase) + s.c,s.p,s.q,s.g,s.base=id,phase,s.q+1,false,nil + s.u=sdk.time.elapsed+15 +end +local function begin_set(s,id) + s.a,s.t,s.d,s.f=id,"",{},{} + turn(s,id,"s") +end +local function result(s) + local id=next_copier(s) + if id then turn(s,id,"c") else s.p,s.g,s.u="r",false,sdk.time.elapsed+2;s.q=s.q+1 end +end +local function read_ack(id) if id==me() then return ack end return sdk.net.read(id,"ack") end +local function matches(a,s) return type(a)=="table" and a.e==s.e and a.q==s.q end +local function host_tick() + local s,now=game,sdk.time.elapsed + if not s or not running then return end + local present={};for _,id in ipairs(roster()) do present[id]=true end + local changed=false + for i=#s.ids,1,-1 do if not present[s.ids[i]] then table.remove(s.ids,i);changed=true end end + if changed then + s.v=s.v+1 + if not present[s.a] then begin_set(s,next_setter(s)) + elseif not present[s.c] and s.p=="c" then result(s) end + publish(s) + end + if s.p=="r" and now>=s.u then + if not s.solo and count(s)<=1 then + s.p,s.g="w",false + for _,id in ipairs(s.ids) do if (s.l[id] or 0)<5 then s.w=id end end + running=false + else s.n=s.n+1;begin_set(s,next_setter(s)) end + publish(s);return + end + if s.p=="s" or s.p=="c" then + if not s.g then + local all=true + for _,id in ipairs(s.ids) do if not matches(read_ack(id),s) then all=false end end + if all then s.g=true;s.u=now+15;publish(s) + elseif now>=s.u then s.p,s.error,running="i","A player did not acknowledge the turn. Enable the same mod on every player and restart.",false;publish(s) end + return + end + if not s.base then + local a=read_ack(s.c) + if matches(a,s) and a.stage==1 then s.base={landing=a.l,bail=a.b};s.u=now+(s.p=="s" and 90 or (sdk.settings.copy_seconds or 45));publish(s) + elseif now>=s.u then s.p,s.error,running="i","Active player did not finish resetting. Restart the session.",false;publish(s) end + return + end + local p=sdk.player.skater(s.c) + if p then + local landed,bail=p.landing_seq or 0,p.bail_seq or 0 + local failed=bail>s.base.bail or now>=s.u + local trick=landed>s.base.landing and not p.bailing and tostring(p.landed_trick or "") or nil + if s.p=="s" then + if failed then begin_set(s,next_setter(s));publish(s) + elseif trick and allowed(trick) then s.t=trick;result(s);publish(s) + elseif trick then s.base.landing=landed end + elseif failed or trick then + if not failed and canon(trick)==canon(s.t) then s.d[s.c]=true + else s.f[s.c]=true;s.l[s.c]=math.min(5,(s.l[s.c] or 0)+1) end + result(s);publish(s) + end + elseif now>=s.u then s.p,s.error,running="i","Active player observations are missing. Restart the session.",false;publish(s) end + end + if now-last_publish>=0.5 then publish(s) end end local function menu(force) - local host = not info().active or info().is_host - local stamp=tostring(host)..tostring(running) + local stamp=tostring(host())..tostring(running) if not force and stamp==menu_stamp then return end menu_stamp=stamp - sdk.ui.menu("session", {title="SKATE",section="Gamemodes",items={ - {id="start",label="Start session",enabled=host and not running,description=not host and "Only the multiplayer host can start this session. Ask the host to start it, or leave multiplayer for a solo drill." or (running and "A session is already running. Stop it before starting a new one." or "Start a new game; solo starts a set-and-match drill.")}, - {id="stop",label="Stop session",enabled=host and running,description=not host and "Only the multiplayer host can stop this session." or (running and "End the current game for everyone." or "No session is running.")}, + sdk.ui.menu("session",{title="SKATE",section="Gamemodes",items={ + {id="start",label="Start session",enabled=host() and not running,description=host() and "Host starts here. Other players take turns from this position." or "Only the multiplayer host can start this session."}, + {id="stop",label="Stop session",enabled=host() and running,description="End the match and restore everyone's skater and camera."}, }}) end -local function host_tick(ids, observed) - local now = sdk.time.elapsed - if not game or game.p == "w" and now >= game.u then - game = {n=1, ids=ids, l={}, w=""} - begin_set(game, ids[1]) - publish(game) - return - end - local s = game - s.ids = ids - local present = {} - for _, id in ipairs(ids) do present[id] = true; s.l[id] = s.l[id] or 0 end - for id in pairs(s.l) do if not present[id] then s.l[id], s.d[id], s.f[id] = nil, nil, nil end end - if not present[s.a] then begin_set(s, next_setter(s)); publish(s); return end - if s.p == "r" and now >= s.u then - local live = alive(s) - if #live == 0 or #ids > 1 and #live == 1 then - s.p, s.w, s.u = "w", live[1] or "", now + 8 - else - s.n = s.n + 1 - begin_set(s, next_setter(s)) - end - publish(s) - return - end - if s.p == "s" then - local e = observed[s.a] - if e and e.trick and allowed(e.trick) then - s.p, s.t, s.d, s.f = "c", e.trick, {}, {} - s.u = now + (sdk.settings.copy_seconds or 45) - publish(s) - return - elseif now >= s.u or e and e.bail then - begin_set(s, next_setter(s)) - publish(s) - return - end - elseif s.p == "c" then - local changed, pending = false, 0 - for _, id in ipairs(alive(s)) do - if copying(s, id) and not s.d[id] and not s.f[id] then - local e = observed[id] - if e and e.trick and canon(e.trick) == canon(s.t) then s.d[id], changed = true, true - elseif e or now >= s.u then fail(s, id); changed = true - else pending = pending + 1 end - end - end - if pending == 0 then s.p, s.u, changed = "r", now+2, true end - if changed then publish(s); return end - end - if now-last_publish >= 1 then publish(s) end -end -local function overlay() - local s, id = read_game(), me() - local status, board = "Waiting for the host...", {} +local function overlay(s) + local status,board="No host match received. Ask the host to start SKATE; all players must enable the same mod version.",{} if s then - for _, peer in ipairs(s.ids) do - local letters = WORD:sub(1, s.l[peer] or 0) - board[#board+1] = name(peer) .. (peer == s.a and " * " or " ") .. (letters ~= "" and letters or "-") - end - local remaining = s.r or math.ceil(math.max(0, (s.u or 0)-sdk.time.elapsed)) - if s.p == "i" then status = "Session stopped. Open Gamemodes > SKATE to start." - elseif s.p == "s" then status = name(s.a) .. ": land a trick to set it." - elseif s.p == "w" then status = s.w ~= "" and (name(s.w) .. " wins. Restarting shortly.") or "Drill finished. Restarting shortly." - elseif s.d[id] then status = "Matched " .. s.t .. "." - elseif s.f[id] then status = "Missed " .. s.t .. "." - elseif copying(s, id) then status = "Copy: " .. s.t .. " (" .. remaining .. "s)" - else status = "Set: " .. s.t .. ". Other skaters are copying." end + for _,id in ipairs(s.ids) do local l=WORD:sub(1,s.l[id] or 0);board[#board+1]=name(id)..(id==s.c and " > " or " ")..(l~="" and l or "-") end + if s.p=="i" then status=s.error or "Session stopped. Open Gamemodes > SKATE to start." + elseif s.p=="w" then status=s.w and name(s.w).." wins!" or "Session finished." + elseif s.p=="r" then status="Round complete: "..s.t + elseif not s.g then status="Preparing "..name(s.c).."'s turn; synchronizing players." + elseif s.p=="s" then status=name(s.c)..": land a trick to set it." + else status=name(s.c)..": copy "..s.t.." ("..tostring(s.r or math.ceil(math.max(0,s.u-sdk.time.elapsed))).."s)" end + if s.c~=me() and s.p~="i" and s.p~="w" then status=status.." Spectating." end end - sdk.ui.text("skate_title", "GAME OF SKATE") - sdk.ui.text("skate_status", status) - sdk.ui.text("skate_board", table.concat(board, " ")) - local last = tostring(sdk.player.read().landed_trick or "") - sdk.ui.text("skate_last", "Last landed trick: " .. (last ~= "" and last or "(none)")) - sdk.ui.text("skate_net", #roster() == 1 and "Solo drill: set a trick, then land it again." or "") + sdk.ui.text("skate_title","GAME OF SKATE") + sdk.ui.text("skate_status",status) + sdk.ui.text("skate_board",table.concat(board," ")) + sdk.ui.text("skate_last","Last landed trick: "..tostring(sdk.player.read().landed_trick or "")) + sdk.ui.text("skate_net",tostring(info().status or "")) end return { - on_load = function() - assert((sdk.capabilities.skater or 0) >= 4, "Update the game: confirmed scoring observations are required") + on_load=function() + assert((sdk.capabilities.player_control or 0)>=1 and (sdk.capabilities.camera or 0)>=3,"Update the game: player suspension and peer camera streaming are required") menu(true) - sdk.log("Game of SKATE ready; start it from Gamemodes > SKATE") end, - on_ui_update = function() - if info().active and not info().is_host then running=false;game=nil end - menu() + on_ui_update=function() + if not host() then running=false;game=nil end + local s=read_game();control(s,false);overlay(s);menu() end, - on_fixed_update = function() - local ids = roster() - local observed = events(ids) - if not info().active or info().is_host then - if running then host_tick(ids, observed) - elseif not game or game.p ~= "i" or sdk.time.elapsed-last_publish>=1 then game=stopped(ids);publish(game) end - else game=nil;running=false end - menu() - overlay() + on_fixed_update=function() + local s=read_game();control(s,true) + if host() then host_tick() end + s=read_game();control(s,false);overlay(s);menu() end, - on_event = function(event) - if event.name == "world_changed" then - seen,game,running={},nil,false; sdk.net.publish("skate",nil);menu(true) - elseif event.name == "menu_action" and event.menu == "session" then - if info().active and not info().is_host then sdk.log("Only the host can start or stop this session");return end - if event.item == "start" then - seen,game,running={},nil,true - host_tick(roster(),{}) - menu() - elseif event.item == "stop" then - running=false;game=stopped(roster());publish(game);menu() - end + on_event=function(event) + if event.name=="world_changed" then release();received,received_host=nil,nil;game,running=nil,false;sdk.net.publish("skate",nil);sdk.net.publish("origin",nil);menu(true) + elseif event.name=="menu_action" and event.menu=="session" and host() then + if event.item=="start" and not running then + release();serial=serial+1 + local p=sdk.player.read() + game={e=math.floor(sdk.time.elapsed*1000)*1000+serial,v=1,q=0,n=1,ids=roster(),l={},w=nil, + origin={position=p.position,heading=p.heading or 0,velocity={0,0,0}}} + game.solo=#game.ids==1;running=true;begin_set(game,me());publish(game);control(game,false);menu() + elseif event.item=="stop" and game then game.p,game.g,running="i",false,false;publish(game);release();menu() end end end, - on_unload = function() - for _, key in ipairs({"skate_title", "skate_status", "skate_board", "skate_last", "skate_net"}) do sdk.ui.text(key, "") end - sdk.net.publish("skate", nil) - sdk.ui.remove_menu("session") + on_unload=function() + release();sdk.net.publish("skate",nil);sdk.net.publish("origin",nil);sdk.ui.remove_menu("session") + for _,key in ipairs({"skate_title","skate_status","skate_board","skate_last","skate_net"}) do sdk.ui.text(key,"") end end, } diff --git a/sdk/examples/game-of-skate/mod.json b/sdk/examples/game-of-skate/mod.json index 108f92a5..cb937bc0 100644 --- a/sdk/examples/game-of-skate/mod.json +++ b/sdk/examples/game-of-skate/mod.json @@ -2,9 +2,9 @@ "id": "examples.game-of-skate", "api": 2, "name": "Game of SKATE", - "version": "0.2.0", + "version": "0.3.0", "author": "Skate3clone", - "description": "One skater sets a trick. Everyone else has to match it or take a letter. Last player without SKATE wins. Works solo as a set-then-match drill.", + "description": "Host-first sequential turns from a shared start. Waiting players are suspended and watch the active player's camera. Match the trick or take a letter; last player without SKATE wins.", "entry": "main.lua", "settings": { "copy_seconds": { diff --git a/sdk/examples/simon-says/README.md b/sdk/examples/simon-says/README.md new file mode 100644 index 00000000..c3169fb8 --- /dev/null +++ b/sdk/examples/simon-says/README.md @@ -0,0 +1,23 @@ +# Simon Says 2.0 + +Enable this version on every player, using the updated game build. The host +starts/stops from **Gamemodes > Simon Says**. Everyone races to land the called +trick; the first confirmed clean ride-away observed by the host wins the round. +Others lose a life. Solo plays as survival against the clock. Category filters, +round timing, escalation, sudden death, hints and the call card remain settings. +Eliminated and late-joining players spectate a surviving player. Stop, match end, +world changes and unloading release the camera and player suspension. + +Each player acknowledges a new round before its results can count. The host +reads replicated engine landing/bail counters and settled scoring metadata; +there is no stick-gesture win detector or client-submitted win claim. Body spins +are separate from board shuvit rotation. A bail during ride-away cancels credit. +All peers need this version and the new engine observation fields. + +Caveman and Acid Drop guide entries are excluded from random calls because the +current extracted scoring catalog has no distinct settled labels for them. +The duplicate FS Fastplant card is de-duplicated. Ground tricks must finish and +produce a confirmed scoring result; merely entering a manual/plant does not win. + +Start/Stop now use the game menu instead of the old ready/reset hotkeys. The +obsolete stick-detector minimum-airtime setting has been removed. diff --git a/sdk/examples/simon-says/SHA256SUMS.txt b/sdk/examples/simon-says/SHA256SUMS.txt new file mode 100644 index 00000000..e36a7f80 --- /dev/null +++ b/sdk/examples/simon-says/SHA256SUMS.txt @@ -0,0 +1,3 @@ +a3f3c066b97ab4318fa58dc4f31d897f498c5cfd9da716c126d836f2ed4069ad main.lua +da06ca442a0270968341e9bb54cd1eec34987046f9434e8f09254ae2c074d7d9 mod.json +b0fe8ba660657ac6ca84e98735fc362ca7ab6c41dc22d73ed2ac50fbe1911e2e README.md diff --git a/sdk/examples/simon-says/main.lua b/sdk/examples/simon-says/main.lua new file mode 100644 index 00000000..f19e39a7 --- /dev/null +++ b/sdk/examples/simon-says/main.lua @@ -0,0 +1,280 @@ +-- Host-authoritative race to copy; only settled engine results count. +local POOL = { + { name = "Kickflip", cat = "flip", hint = "RS down, flick UP+RIGHT", ghint = "RS down, flick UP+LEFT", color = { 0.23, 0.79, 0.98, 1 } }, + { name = "Heelflip", cat = "flip", hint = "RS down, flick UP+LEFT", ghint = "RS down, flick UP+RIGHT", color = { 1.00, 0.62, 0.15, 1 } }, + { name = "Pop Shuvit", cat = "shuvit", hint = "RS scoop RIGHT side", ghint = "RS scoop LEFT side", color = { 0.70, 0.50, 1.00, 1 } }, + { name = "360 Flip", cat = "flip", hint = "RS scoop L -> DOWN -> UR", ghint = "RS scoop R -> DOWN -> UL", color = { 1.00, 0.84, 0.20, 1 } }, + { name = "Nose Grab", cat = "grab", hint = "AIR: hold LT + RS DOWN", ghint = "AIR: hold RT + RS DOWN", color = { 0.35, 0.95, 0.45, 1 } }, + { name = "Ollie", cat = "ollie", hint = "RS down, pop straight UP", ghint = "RS down, pop straight UP", color = { 0.75, 0.85, 1.00, 1 } }, + { name = "Seatbelt", cat = "grab", hint = "AIR: hold LT + RS UP", ghint = "AIR: hold RT + RS UP", color = { 1.00, 0.45, 0.75, 1 } }, + { name = "Nollie", cat = "ollie", hint = "RS tap UP then DOWN", ghint = "RS tap UP then DOWN", color = { 0.80, 0.85, 0.90, 1 } }, + { name = "Varial Kickflip", cat = "flip", hint = "RS down-LEFT to UP+RIGHT", ghint = "RS down-RIGHT to UP+LEFT", color = { 0.20, 0.90, 0.80, 1 } }, + { name = "Varial Heelflip", cat = "flip", hint = "RS down-RIGHT to UP+LEFT", ghint = "RS down-LEFT to UP+RIGHT", color = { 1.00, 0.45, 0.30, 1 } }, + { name = "360 Pop Shuvit", cat = "shuvit", hint = "RS scoop L,DOWN,RIGHT", ghint = "RS scoop R,DOWN,LEFT", color = { 0.85, 0.70, 1.00, 1 } }, + { name = "FS Pop Shuvit", cat = "shuvit", hint = "RS scoop LEFT side", ghint = "RS scoop RIGHT side", color = { 0.55, 0.65, 1.00, 1 } }, + { name = "Double Kickflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 0.30, 0.70, 1.00, 1 } }, + { name = "Double Heelflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 1.00, 0.55, 0.20, 1 } }, + { name = "Nollie Kickflip", cat = "flip", hint = "Nollie: flick to heelside", ghint = "Nollie: flick to heelside", color = { 0.45, 0.85, 0.90, 1 } }, + { name = "Nollie Heelflip", cat = "flip", hint = "Nollie: flick to toeside", ghint = "Nollie: flick to toeside", color = { 0.90, 0.60, 0.35, 1 } }, + { name = "Hardflip", cat = "flip", hint = "FS shuvit + kickflip. Awkward!", ghint = "FS shuvit + kickflip. Awkward!", color = { 1.00, 0.30, 0.45, 1 } }, + { name = "Laser Flip", cat = "flip", hint = "FS 360 shuvit + heelflip", ghint = "FS 360 shuvit + heelflip", color = { 0.60, 1.00, 0.40, 1 } }, + { name = "Inward Heelflip", cat = "flip", hint = "Shuvit + heelflip", ghint = "Shuvit + heelflip", color = { 1.00, 0.50, 0.60, 1 } }, + { name = "360 Hardflip", cat = "flip", hint = "FS 360 shuvit + kickflip", ghint = "FS 360 shuvit + kickflip", color = { 0.95, 0.35, 0.55, 1 } }, + { name = "Melon", cat = "grab", hint = "Grab BS, push board forwards", ghint = "Grab BS, push board forwards", color = { 0.40, 0.90, 0.50, 1 } }, + { name = "Method", cat = "grab", hint = "Grab and tweak it out", ghint = "Grab and tweak it out", color = { 0.50, 1.00, 0.60, 1 } }, + { name = "Mute", cat = "grab", hint = "Board sideways, front grab", ghint = "Board sideways, front grab", color = { 0.65, 0.95, 0.45, 1 } }, + { name = "Stale", cat = "grab", hint = "Board sideways, back grab", ghint = "Board sideways, back grab", color = { 0.75, 0.90, 0.40, 1 } }, + { name = "Crail", cat = "grab", hint = "Nose reach, watch the hand", ghint = "Nose reach, watch the hand", color = { 0.45, 0.85, 0.55, 1 } }, + { name = "Tail Grab", cat = "grab", hint = "Push forward, back-hand grab", ghint = "Push forward, back-hand grab", color = { 0.55, 0.80, 0.50, 1 } }, + { name = "50-50", cat = "grind", fam = true, hint = "Both trucks. Line up rail", ghint = "Both trucks. Line up rail", color = { 0.90, 0.90, 0.95, 1 } }, + { name = "Smith Grind", cat = "grind", fam = true, hint = "Line up Smith and grind", ghint = "Line up Smith and grind", color = { 0.88, 0.86, 0.90, 1 } }, + { name = "Krooked", cat = "grind", fam = true, hint = "Nose into it, crooked grind", ghint = "Nose into it, crooked grind", color = { 0.86, 0.80, 0.92, 1 } }, + { name = "Nose Blunt", cat = "grind", fam = true, hint = "Nose up onto the blunt", ghint = "Nose up onto the blunt", color = { 0.84, 0.82, 0.94, 1 } }, + { name = "Feeble", cat = "grind", fam = true, hint = "Front truck hangs feeble", ghint = "Front truck hangs feeble", color = { 0.82, 0.86, 0.92, 1 } }, + { name = "Salad", cat = "grind", fam = true, hint = "Salad grind, hold it", ghint = "Salad grind, hold it", color = { 0.80, 0.90, 0.88, 1 } }, + { name = "Blunt Slide", cat = "grind", fam = true, hint = "Up on the blunt, slide it", ghint = "Up on the blunt, slide it", color = { 0.87, 0.83, 0.93, 1 } }, + { name = "Willie", cat = "grind", fam = true, hint = "Willie grind, steezy", ghint = "Willie grind, steezy", color = { 0.83, 0.89, 0.91, 1 } }, + { name = "Over-Krooks", cat = "grind", fam = true, also = { "overkrook", "overkrooked", "krooks" }, hint = "Over the krooks", ghint = "Over the krooks", color = { 0.85, 0.81, 0.93, 1 } }, + { name = "Nose Manual", ground = true, cat = "manual", fam = true, hint = "Push partway, sweet spot", ghint = "Push partway, sweet spot", color = { 0.70, 0.95, 0.85, 1 } }, + { name = "BS Fastplant", ground = true, cat = "manual", fam = true, hint = "BS grab, push off back foot", ghint = "BS grab, push off back foot", color = { 0.95, 0.75, 0.45, 1 } }, + { name = "BS Handplant", ground = true, cat = "manual", fam = true, hint = "Ramp angle, hold at edge", ghint = "Ramp angle, hold at edge", color = { 0.93, 0.70, 0.50, 1 } }, + { name = "FS 360 Pop Shuvit", cat = "shuvit", hint = "Find the start spot", ghint = "Find the start spot", color = { 0.65, 0.60, 1.00, 1 } }, + { name = "Nollie 360 Flip", cat = "flip", hint = "Nollie shuvit + kickflip", ghint = "Nollie shuvit + kickflip", color = { 0.50, 0.80, 0.95, 1 } }, + { name = "FS Fastplant", ground = true, cat = "manual", fam = true, hint = "FS grab, push off front foot", ghint = "FS grab, push off front foot", color = { 0.94, 0.78, 0.42, 1 } }, + { name = "Triple Kickflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 0.25, 0.65, 1.00, 1 } }, + { name = "Triple Heelflip", cat = "flip", hint = "Flick, hold to keep flipping", ghint = "Flick, hold to keep flipping", color = { 1.00, 0.50, 0.25, 1 } }, + { name = "360 Spin", cat = "spin", spin = 360, hint = "Spin 360 either way, stomp it", ghint = "Spin 360 either way, stomp it", color = { 0.40, 1.00, 0.70, 1 } }, + { name = "540 Spin", cat = "spin", spin = 540, hint = "Spin 540 either way, stomp it", ghint = "Spin 540 either way, stomp it", color = { 0.45, 0.95, 0.75, 1 } }, + { name = "720 Spin", cat = "spin", spin = 720, hint = "Spin 720 either way, stomp it", ghint = "Spin 720 either way, stomp it", color = { 0.50, 0.90, 0.80, 1 } }, + { name = "Kickflip 180", cat = "combo", combo = { flip = "KICKFLIP", pool = 1, spin = 180 }, hint = "Kickflip + spin 180 either way", ghint = "Kickflip + spin 180 either way", color = { 0.30, 0.75, 1.00, 1 } }, + { name = "Kickflip 360", cat = "combo", combo = { flip = "KICKFLIP", pool = 1, spin = 360 }, hint = "Kickflip + spin 360 either way", ghint = "Kickflip + spin 360 either way", color = { 0.28, 0.72, 1.00, 1 } }, + { name = "Heelflip 180", cat = "combo", combo = { flip = "HEELFLIP", pool = 2, spin = 180 }, hint = "Heelflip + spin 180 either way", ghint = "Heelflip + spin 180 either way", color = { 1.00, 0.58, 0.22, 1 } }, + { name = "Heelflip 360", cat = "combo", combo = { flip = "HEELFLIP", pool = 2, spin = 360 }, hint = "Heelflip + spin 360 either way", ghint = "Heelflip + spin 360 either way", color = { 1.00, 0.55, 0.20, 1 } }, + { name = "Ollie 180", cat = "combo", combo = { flip = "OLLIE", pool = 6, spin = 180 }, hint = "Ollie + spin 180 either way", ghint = "Ollie + spin 180 either way", color = { 0.72, 0.82, 1.00, 1 } }, + { name = "Ollie 360", cat = "combo", combo = { flip = "OLLIE", pool = 6, spin = 360 }, hint = "Ollie + spin 360 either way", ghint = "Ollie + spin 360 either way", color = { 0.70, 0.80, 1.00, 1 } }, + { name = "360 Flip 180", cat = "combo", combo = { flip = "360_FLIP", pool = 4, spin = 180 }, hint = "Treflip + spin 180 either way", ghint = "Treflip + spin 180 either way", color = { 0.95, 0.80, 0.25, 1 } }, + { name = "Melon 360", cat = "combo", combo = { flip = "MELON", pool = 21, spin = 360 }, hint = "Melon + spin 360 either way", ghint = "Melon + spin 360 either way", color = { 0.42, 0.88, 0.52, 1 } }, + { name = "Nose Grab 360", cat = "combo", combo = { flip = "NOSE_GRAB", pool = 5, spin = 360 }, hint = "Nose grab + spin 360 either way", ghint = "Nose grab + spin 360 either way", color = { 0.38, 0.92, 0.48, 1 } }, + { name = "Method 360", cat = "combo", combo = { flip = "METHOD", pool = 22, spin = 360 }, hint = "Method + spin 360 either way", ghint = "Method + spin 360 either way", color = { 0.52, 0.98, 0.62, 1 } }, + { name = "Superdude", cat = "grab", hint = "Grab + superman out huge", ghint = "Grab + superman out huge", color = { 0.60, 0.90, 0.50, 1 } }, + { name = "Miracle Whip", cat = "grab", hint = "Whip it. It's a grab.", ghint = "Whip it. It's a grab.", color = { 0.65, 0.88, 0.48, 1 } }, + { name = "Late Kickflip", cat = "flip", hint = "Ollie first, flip late", ghint = "Ollie first, flip late", color = { 0.32, 0.72, 1.00, 1 } }, + { name = "Late Heelflip", cat = "flip", hint = "Ollie first, flip late", ghint = "Ollie first, flip late", color = { 1.00, 0.57, 0.23, 1 } }, + { name = "Late Shuvit", cat = "shuvit", hint = "Ollie first, shuvit late", ghint = "Ollie first, shuvit late", color = { 0.72, 0.52, 1.00, 1 } }, + { name = "Kickflip Underflip", cat = "flip", hint = "Flip it, then flip it back", ghint = "Flip it, then flip it back", color = { 0.34, 0.70, 1.00, 1 } }, + { name = "Heelflip Underflip", cat = "flip", hint = "Flip it, then flip it back", ghint = "Flip it, then flip it back", color = { 1.00, 0.53, 0.25, 1 } }, + { name = "Nollie Pop Shuvit", cat = "shuvit", hint = "Nollie stance shuvit", ghint = "Nollie stance shuvit", color = { 0.68, 0.62, 1.00, 1 } }, + { name = "FS Fastplant", ground = true, cat = "manual", fam = true, hint = "FS grab, push front foot", ghint = "FS grab, push front foot", color = { 0.94, 0.76, 0.44, 1 } }, + { name = "Airwalk", cat = "grab", hint = "Airwalk it out", ghint = "Airwalk it out", color = { 0.62, 0.89, 0.49, 1 } }, + { name = "Coffin", ground = true, cat = "grab", hint = "Lay back, feet up. Coffin", ghint = "Lay back, feet up. Coffin", color = { 0.58, 0.86, 0.52, 1 } }, + { name = "Cross Bone", cat = "grab", hint = "Cross-bone grab", ghint = "Cross-bone grab", color = { 0.66, 0.87, 0.47, 1 } }, + { name = "Caveman", ground = true, cat = "ollie", hint = "Step off, jump back on", ghint = "Step off, jump back on", color = { 0.74, 0.84, 1.00, 1 } }, + { name = "Acid Drop", ground = true, cat = "ollie", hint = "Ride off a drop", ghint = "Ride off a drop", color = { 0.73, 0.83, 1.00, 1 } }, +} + +local game, received, epoch = nil, nil, 0 +local last_publish, menu_stamp, watching = -1, nil, false +local deck = {} +local acknowledged +local function option(k,d) local v=sdk.settings[k];if v==nil then return d end;return v end +local function net() return sdk.net.info() end +local function me() return tostring(net().local_id or "0") end +local function host() return not net().active or net().is_host end +local function now() return sdk.time.elapsed end +local function canon(s) + s=tostring(s or ""):lower():gsub("id_trick_",""):gsub("_"," ") + for _,category in ipairs({"ground","trick","flip","grab","grind","authentic","air","metrics"}) do + s=s:gsub("%f[%w]"..category.."%f[%W]","") + end + s=s:gsub("%f[%w]switch%f[%W]",""):gsub("%f[%w]fakie%f[%W]","") + return (s:gsub("[^%w]","")) +end +local aliases={stalegrab="stale",stalefish="stale",crailgrab="crail",seatbeltgrab="seatbelt", + mutegrab="mute",superman="superdude",ngairwalk="airwalk",crossbone="crossbone", + crooked="krooked",crookedgrind="krooked",krooks="krooked",nosebluntslide="noseblunt", + popshoveit="popshuvit",shuvit="popshuvit",["360shuvit"]="360popshuvit"} +local function key(s) local k=canon(s);return aliases[k] or k end +local function matches(label,t,p) + -- The scorer appends body rotation after the board/grab name. A leading + -- 360 in "360 Flip" is a board trick, never evidence of a body spin. + local spin=tonumber(tostring(label):match("%s(%d+)%s*$")) + local base=tostring(label):gsub("%s%d+%s*$","") + if p.landed_spin_degrees~=nil then spin=math.abs(p.landed_spin_degrees) end + local raw=p.landed_trick_base or "" + if base:lower():find("halfcab",1,true) then spin=180;base=base:gsub("[FfBb][Ss] ?[Hh]alf[Cc]ab","") end + if t.spin then + return spin==t.spin + end + if t.combo then + return spin==t.combo.spin and (key(base:gsub("^[FfBb][Ss] ",""))==key(POOL[t.combo.pool].name) or raw~="" and key(raw)==key(POOL[t.combo.pool].name)) + end + if t.cat=="grind" or t.cat=="grab" then base=base:gsub("^[FfBb][Ss] ","") end + local k=key(base) + if k==key(t.name) or raw~="" and key(raw)==key(t.name) then return true end + for _,a in ipairs(t.also or {}) do if k==key(a) then return true end end + return false +end +local function enabled(t) + -- The extracted scoring catalog has no settled Caveman/Acid Drop labels. + -- Keep their guide entries, but never issue an unconfirmable challenge. + if t.name=="Caveman" or t.name=="Acid Drop" then return false end + local keys={flip="allow_flips",shuvit="allow_shuvits",ollie="allow_ollies",grab="allow_grabs",grind="allow_grinds",manual="allow_manuals_plants",spin="allow_spins"} + if t.combo then return option("allow_spins",true) and enabled(POOL[t.combo.pool]) end + return option(keys[t.cat],true) +end +local function draw() + if #deck==0 then + local seen={} + for i,t in ipairs(POOL) do if enabled(t) and not seen[t.name] then deck[#deck+1]=i;seen[t.name]=true end end + for i=#deck,2,-1 do local j=math.random(i);deck[i],deck[j]=deck[j],deck[i] end + end + return table.remove(deck) +end +local function publish() + if not game then sdk.net.publish("match",nil);return end + local l={};for _,id in ipairs(game.ids) do l[#l+1]=tostring(game.l[id] or 0) end + sdk.net.publish("match",{e=game.e,p=game.p,n=game.n,c=game.c,ids=game.ids,l=table.concat(l), + r=math.ceil(math.max(0,game.until_at-now())),w=game.w or 0}) + last_publish=now() +end +local function read() + if host() then return game end + local w=sdk.net.read(tostring(net().host_id),"match") + if type(w)~="table" or type(w.ids)~="table" or type(w.l)~="string" then received=nil;return nil end + local s={e=w.e,p=w.p,n=w.n,c=w.c,ids=w.ids,l={},r=w.r,w=w.w} + for i,id in ipairs(w.ids) do s.l[id]=tonumber(w.l:sub(i,i)) or 0 end + received=s;return s +end +local function release() + if watching then sdk.player.suspend(false);sdk.camera.watch(nil);watching=false end +end +local function controls(s) + if s and s.p=="copy" then + local token=tostring(s.e)..":"..s.n + if acknowledged~=token then + local p=sdk.player.read() + sdk.net.publish("round",{e=s.e,n=s.n,l=p.landing_seq or 0,b=p.bail_seq or 0}) + acknowledged=token + end + end + local out=s and s.p~="done" and s.p~="stopped" and (s.l[me()] or 0)<=0 + if not out then release();return end + sdk.player.suspend(true);watching=true + local target + if option("spec_cam",true) then for _,id in ipairs(s.ids) do if (s.l[id] or 0)>0 then target=id;break end end end + sdk.camera.watch(target) +end +local function menu() + local s=read();local active=s and s.p~="done" and s.p~="stopped" + local stamp=tostring(host())..tostring(active) + if stamp==menu_stamp then return end;menu_stamp=stamp + sdk.ui.menu("challenge",{title="Simon Says",section="Gamemodes",items={ + {id="start",label="Start match",enabled=host() and not active,description="Everyone races to land the called trick. First clean ride-away wins."}, + {id="stop",label="Stop match",enabled=host() and active}, + }}) +end +local function next_round() + game.n=game.n+1;game.c=draw() + if not game.c then game.p="stopped";publish();return end + game.p="countdown";game.until_at=now()+option("countdown_secs",3);game.w=0 + game.base={};game.pending={};publish() +end +local function start() + epoch=epoch+1;deck={} + local ids,have={me()},{[me()]=true} + for _,id in ipairs(sdk.net.players()) do id=tostring(id);if not have[id] then ids[#ids+1]=id;have[id]=true end end + game={e=tostring(me())..":"..epoch..":"..now(),ids=ids,l={},n=0,until_at=now()} + for _,id in ipairs(ids) do game.l[id]=option("lives",3) end + next_round() +end +local function resolve(winner) + game.w=winner or 0 + local cost=option("sudden_on",false) and game.n>=option("sudden_round",6) and option("sudden_cost",2) or 1 + local alive=0 + for i,id in ipairs(game.ids) do + if i~=winner then game.l[id]=math.max(0,game.l[id]-cost) end + if game.l[id]>0 then alive=alive+1 end + end + game.p=(alive==0 or #game.ids>1 and alive<=1) and "done" or "break" + game.until_at=now()+math.max(1,option("intermission_base",4)*option("esc_dur",0.85)^(game.n-1));publish() +end +local function advance() + if not game or game.p=="done" or game.p=="stopped" then return end + local connected={[me()]=true};for _,id in ipairs(sdk.net.players()) do connected[tostring(id)]=true end + for _,id in ipairs(game.ids) do if not connected[id] then game.l[id]=0 end end + if game.p=="countdown" and now()>=game.until_at then + game.p="copy";game.until_at=now()+math.max(5,option("round_time",45)*option("esc_dur",0.85)^(game.n-1)) + game.base={} + publish() + elseif game.p=="break" and now()>=game.until_at then next_round() + elseif game.p=="copy" then + local ride=math.max(0.5,option("ride_away",2)-option("esc_ride",0.2)*(game.n-1)) + for i,id in ipairs(game.ids) do + local p=sdk.player.skater(id);local b=game.base[id] + if not b then + local ack=sdk.net.read(id,"round") + if type(ack)=="table" and ack.e==game.e and ack.n==game.n and type(ack.l)=="number" and type(ack.b)=="number" then + b={l=ack.l,b=ack.b};game.base[id]=b + end + end + if p and b and game.l[id]>0 then + local landed,bail=p.landing_seq or 0,p.bail_seq or 0 + if bail~=b.b or p.bailing or p.on_board==false then game.pending[id]=nil end + if landed>b.l and bail==b.b and not p.bailing and (p.landed_clean==nil and p.clean~=false or p.landed_clean==true) and not (p.landed_sketchy or p.sketchy) and matches(p.landed_trick,POOL[game.c],p) then + game.pending[id]={at=now()+ride,b=bail} + end + b.l,b.b=landed,bail + local pending=game.pending[id] + if pending and now()>=pending.at and bail==pending.b and p.on_board~=false and not p.bailing then resolve(i);return end + else game.pending[id]=nil end + end + if now()>=game.until_at then resolve(nil);return end + end + if now()-last_publish>=0.25 then publish() end +end +local card_token, card_at = nil, 0 +local function card(s) + local t=s and POOL[s.c] + local token=s and tostring(s.e)..":"..s.n or "idle" + if token~=card_token then card_token,card_at=token,now() end + local accent=t and t.color or {0.23,0.79,0.98,1} + local pop=option("flash",true) and 1+math.max(0,0.15-(now()-card_at)*0.3) or 1 + local hint=t and (option("stance","regular")=="goofy" and t.ghint or t.hint) or "First clean ride-away wins the round." + local remaining=s and (s.r or math.ceil(math.max(0,s.until_at-now()))) or 0 + local items={ + {key="back",type="rect",position={0,0},size={420,220},color={0.04,0.05,0.06,0.94}}, + {key="accent",type="rect",position={0,0},size={420,6},color=accent}, + {key="title",type="text",position={16,14},size={388,24},text="SIMON SAYS"..(s and " | ROUND "..s.n or ""),font_size=16,color=accent}, + {key="call",type="text",position={16,46},size={388,48},text=t and t.name or "Ready to play",font_size=28,color={1,1,1,1}}, + {key="hint",type="text",position={16,96},size={388,38},text=hint,font_size=14,color={0.85,0.9,0.95,1}}, + {key="state",type="text",position={16,140},size={388,24},text=s and (s.p.." | "..remaining.."s | Lives: "..(s.l[me()] or 0)) or "Open Gamemodes > Simon Says",font_size=17,color=accent}, + {key="help",type="text",position={16,182},size={388,22},text="Start / Stop: Gamemodes > Simon Says",font_size=13,color={0.6,0.65,0.7,1}}, + } + sdk.ui.canvas("simon_card",{anchor="top_right",offset={24,24},size={420,220},scale=option("card_scale",1)*pop,visible=true,items=items}) +end +local function hud() + local s=read();controls(s);menu();card(s) + if not option("show_hud",true) then sdk.ui.text("simon","");return end + if not s then sdk.ui.text("simon","SIMON SAYS | Open Gamemodes > Simon Says. All players must enable this mod.");return end + local t=POOL[s.c];local lines={"SIMON SAYS | Round "..s.n.." | "..s.p} + if t then + lines[#lines+1]="Call: "..t.name.." | "..tostring(s.r or math.ceil(math.max(0,s.until_at-now()))).."s" + lines[#lines+1]=option("stance","regular")=="goofy" and t.ghint or t.hint + end + for i,id in ipairs(s.ids) do local p=sdk.player.skater(id);lines[#lines+1]=(id==me() and "You" or p and p.name or id)..": "..(s.l[id] or 0).." lives"..(s.w==i and " - round winner" or "") end + if s.p=="done" then lines[#lines+1]="Match ended. Start again from Gamemodes > Simon Says." end + sdk.ui.text("simon",table.concat(lines,"\n")) +end +return { + on_load=function() menu();hud() end, + on_fixed_update=function() if host() then advance() end;hud() end, + on_event=function(e) + if e.name=="world_changed" then release();game,received=nil,nil;publish();menu() + elseif e.name=="menu_action" and e.menu=="challenge" and host() then + if e.item=="start" and (not game or game.p=="done" or game.p=="stopped") then start() + elseif e.item=="stop" and game then game.p="stopped";publish();release() end + hud() + end + end, + on_unload=function() release();sdk.net.publish("match",nil);sdk.ui.remove_menu("challenge");sdk.ui.remove("simon_card");sdk.ui.text("simon","") end, +} diff --git a/sdk/examples/simon-says/mod.json b/sdk/examples/simon-says/mod.json new file mode 100644 index 00000000..7c696534 --- /dev/null +++ b/sdk/examples/simon-says/mod.json @@ -0,0 +1,177 @@ +{ + "id": "simon.says", + "api": 2, + "name": "Simon Says", + "version": "2.0.0", + "author": "you", + "description": "Race to copy confirmed landed tricks, with host authority, lives, ride-away checks and eliminated-player spectating.", + "entry": "main.lua", + "settings": { + "lives": { + "type": "number", + "label": "Lives", + "description": "Lives before elimination.", + "default": 3, + "min": 1, + "max": 5, + "step": 1 + }, + "round_time": { + "type": "number", + "label": "Round time (s)", + "description": "Seconds to land the called trick and ride away.", + "default": 45, + "min": 15, + "max": 120, + "step": 5 + }, + "ride_away": { + "type": "number", + "label": "Ride-away clean (s)", + "description": "Clean landing window for v1: on_board + not bailing.", + "default": 2.0, + "min": 1.0, + "max": 4.0, + "step": 0.5 + }, + "stance": { + "type": "choice", + "label": "Your stance", + "description": "Match your skater: goofy mirrors left/right flicks and hints.", + "default": "regular", + "choices": [ + "regular", + "goofy" + ] + }, + "show_hud": { + "type": "boolean", + "label": "Top-left text HUD", + "description": "Show additional top-left call and lives text. The call card remains visible.", + "default": true + }, + "countdown_secs": { + "type": "number", + "label": "Countdown (s)", + "description": "Get-ready countdown before round 1.", + "default": 3, + "min": 1, + "max": 10, + "step": 0.5 + }, + "intermission_base": { + "type": "number", + "label": "Break between rounds (s)", + "description": "Round 1 breather. Shrinks as rounds climb.", + "default": 4, + "min": 1, + "max": 8, + "step": 0.5 + }, + "sudden_on": { + "type": "boolean", + "label": "Sudden death", + "description": "Late rounds hit harder. Off keeps it chill.", + "default": false + }, + "sudden_round": { + "type": "number", + "label": "Sudden death starts round", + "description": "Fail costs double from here.", + "default": 6, + "min": 3, + "max": 12, + "step": 1 + }, + "sudden_cost": { + "type": "number", + "label": "Sudden death cost", + "description": "Lives lost per fail in sudden death.", + "default": 2, + "min": 1, + "max": 3, + "step": 1 + }, + "esc_dur": { + "type": "number", + "label": "Timer squeeze / round", + "description": "Round time multiplier each round. Lower = meaner.", + "default": 0.85, + "min": 0.7, + "max": 1, + "step": 0.01 + }, + "esc_ride": { + "type": "number", + "label": "Ride-out shrink / round (s)", + "description": "Ride-away loses this much each round.", + "default": 0.2, + "min": 0, + "max": 0.5, + "step": 0.05 + }, + "spec_cam": { + "type": "boolean", + "label": "Spectate camera", + "description": "Eliminated riders watch through a surviving player camera.", + "default": true + }, + "allow_flips": { + "type": "boolean", + "label": "Flip tricks", + "description": "Kickflips, heelflips, varials, hardflips, lasers, doubles, nollie flips.", + "default": true + }, + "allow_shuvits": { + "type": "boolean", + "label": "Shuv-its", + "description": "Pop shuvits, FS and 360 variants.", + "default": true + }, + "allow_ollies": { + "type": "boolean", + "label": "Ollies", + "description": "Ollie and nollie calls.", + "default": true + }, + "allow_grabs": { + "type": "boolean", + "label": "Grabs", + "description": "Nose, seatbelt, melon, method, mute, stale, crail, tail.", + "default": true + }, + "allow_grinds": { + "type": "boolean", + "label": "Grinds", + "description": "50-50, smith, krooked, blunts, feeble, salad, willie.", + "default": true + }, + "allow_manuals_plants": { + "type": "boolean", + "label": "Manuals and plants", + "description": "Nose manual, fastplants, handplants.", + "default": true + }, + "allow_spins": { + "type": "boolean", + "label": "Spins and combos", + "description": "Pure spins plus flip-plus-spin combos. Off removes both.", + "default": true + }, + "card_scale": { + "type": "number", + "label": "Card size", + "description": "Top-right card scale.", + "default": 1, + "min": 0.65, + "max": 1.5, + "step": 0.05 + }, + "flash": { + "type": "boolean", + "label": "Call flash", + "description": "Card pops big on each new call.", + "default": true + } + } +} diff --git a/sdk/examples/wipeout-challenge/README.md b/sdk/examples/wipeout-challenge/README.md new file mode 100644 index 00000000..36f23c8a --- /dev/null +++ b/sdk/examples/wipeout-challenge/README.md @@ -0,0 +1,21 @@ +# Wipeout Challenge 3.1 + +Requires the updated game build with `player_overlap` capability. Enable the +mod, then use **Gamemodes > Wipeout**: Start, Restart at start pad, or Stop and +return. T starts an idle run; R restarts. Each local run is a personal survival +challenge, not a shared multiplayer race or leaderboard. + +Start builds the supplied four-pad sky course 40m above the saved starting +location. Restart reuses that anchor rather than lifting another 40m. Stop and +unload restore the original position and remove all course bodies and meshes. +A map change removes the course without teleporting into the previous map. + +Hazards are persistent kinematic solids moved every fixed tick, not repeatedly +removed/respawned static boxes. Hits use enabled native board/skater collision +shapes through `sdk.physics.read(key).player_overlapping`. The first hazard +entry is forgiven; the next separate entry ends the run. Sustained contact does +not count repeatedly. One second of recovery groups simultaneous impacts. +Water sensors and falling off the course end the run immediately. Water volumes +extend below their original surface to catch fast falls. Timed mode clears after +the selected waves; survival repeats them. The obsolete safe-marker settings +were removed because they never provided a physical safe area. diff --git a/sdk/examples/wipeout-challenge/SHA256SUMS.txt b/sdk/examples/wipeout-challenge/SHA256SUMS.txt new file mode 100644 index 00000000..ea249fcc --- /dev/null +++ b/sdk/examples/wipeout-challenge/SHA256SUMS.txt @@ -0,0 +1,3 @@ +b2cf588b2b14b818d574d62f6e8c5d9f76ae256d9171871ec3b0b80b16f3b44b main.lua +c18fa0fe6f9259b7310c9dc063784ca265889a4dbfb77e95286550702eda6cc1 mod.json +e79c9177d3a4a37ae52ac23c8b95e02c7a122398706925b8de2416fb8d28f4af README.md diff --git a/sdk/examples/wipeout-challenge/main.lua b/sdk/examples/wipeout-challenge/main.lua new file mode 100644 index 00000000..29a1c7ca --- /dev/null +++ b/sdk/examples/wipeout-challenge/main.lua @@ -0,0 +1,305 @@ +local WAVE_LEN = 40 +local MAXZ = 24 +local SKY_LIFT = 40 +local FALL_MARGIN = 12 + +local RED = { 1, 0.12, 0.12 } +local WATER = { 0.1, 0.55, 1 } +local SPIKE = { 1, 0.5, 0.05 } +local DECK = { 0.55, 0.6, 0.7 } +local STARTPAD = { 0.15, 0.9, 0.35 } + +local run = { + started = false, over = false, + bail_used = false, hits = 0, + t0 = 0, + ax = 0, ay = 0, az = 0, + built = false, grace_until = 0, +} +local prev = { r = false, t = false } +local slots = {} +for i = 1, MAXZ do slots[i] = { live = false, x = 0, y = 0, z = 0, hx = 0, hy = 0, hz = 0, kind = 0 } end +local scratch = {} + +local function fmt_time(s) + if s < 0 then s = 0 end + local m = math.floor(s / 60) + local sec = math.floor(s % 60) + local ms = math.floor((s * 1000) % 1000) + return string.format("%d:%02d.%03d", m, sec, ms) +end + +local function setting(key, fallback) + local v = sdk.settings[key] + if v == nil then return fallback end + return v +end + +local function intensity_scale() + local h = setting("hazard_intensity", "hardcore") + if h == "easy" then return 0.5 end + if h == "normal" then return 0.75 end + return 1 +end + +local function wave_count() + local w = math.floor(setting("wave_count", 4)) + if w < 1 then w = 1 end + if w > 10 then w = 10 end + return w +end + +local function prand(seed) + local x = math.sin(seed * 127.1) * 43758.5 + return x - math.floor(x) +end + +local function slot_key(i) + return string.format("wz%02d", i) +end + +local function zone_color(kind) + if kind == 2 then return WATER end + if kind == 3 then return SPIKE end + return RED +end + +-- Course pieces, anchor-relative. Water keeps its original surface height +-- with depth below it so a falling skater cannot skip a thin sampled sensor. +-- kind 9 = deck platform (no hit rules). +local function course_pieces() + return { + { key = "wo.p1", x = 0, y = 0, z = 0, hx = 7, hy = 0.5, hz = 7, kind = 9, color = STARTPAD }, + { key = "wo.p2", x = 17, y = 1, z = 0, hx = 5, hy = 0.5, hz = 5, kind = 9, color = DECK }, + { key = "wo.p3", x = 17, y = 2, z = 17, hx = 5, hy = 0.5, hz = 5, kind = 9, color = DECK }, + { key = "wo.p4", x = 0, y = 3, z = 17, hx = 7, hy = 0.5, hz = 7, kind = 9, color = DECK }, + { key = "wo.w1", x = 8.5, y = -1.22, z = 0, hx = 2.5, hy = 2.0, hz = 6, kind = 2, sensor = true }, + { key = "wo.w2", x = 17, y = -0.22, z = 8.5, hx = 4, hy = 2.0, hz = 2.5, kind = 2, sensor = true }, + { key = "wo.w3", x = 8.5, y = 0.78, z = 17, hx = 2.5, hy = 2.0, hz = 6, kind = 2, sensor = true }, + } +end + +local function build_course() + local ax, ay, az = run.ax, run.ay, run.az + for _, pc in ipairs(course_pieces()) do + local desc = { + shape = { type = "box", half_extents = { pc.hx, pc.hy, pc.hz } }, + body_type = "static", + position = { ax + pc.x, ay + pc.y, az + pc.z }, + friction = 0.9, + } + if pc.sensor then desc.sensor = true end + sdk.physics.spawn(pc.key, desc) + sdk.graphics.mesh(pc.key .. "v", { + body = pc.key, + scale = { pc.hx * 2, pc.hy * 2, pc.hz * 2 }, + color = pc.color or zone_color(pc.kind), + }) + end + run.built = true +end + +local function clear_course() + for _, pc in ipairs(course_pieces()) do + sdk.physics.remove(pc.key) + sdk.graphics.remove(pc.key .. "v") + end + run.built = false +end + +local function start_pad_top() + -- Pad corner, clear of the wave-1 orbit ring. + return { run.ax - 5, run.ay + 1.6, run.az - 5 } +end + +local function teleport_to_start() + local s = start_pad_top() + sdk.player.teleport({position=s, heading=0, velocity={0,0,0}}) +end + +-- Moving hazards, anchor-relative. kind 1=red 2=water 3=spike. +local function gen_zones(wave, t, out) + local ax, ay, az = run.ax, run.ay, run.az + local n = 0 + local scale = intensity_scale() + local function add(x, y, z, hx, hy, hz, kind) + n = n + 1 + if n > MAXZ then return end + out[n] = out[n] or {} + local zn = out[n] + zn.x, zn.y, zn.z, zn.hx, zn.hy, zn.hz, zn.kind = x, y, z, hx, hy, hz, kind + end + if wave == 1 then + local count = math.floor(6 * scale + 0.5) + for i = 1, count do + local a = (i - 1) * math.pi * 2 / count + t * 0.35 + local r = 4.5 + math.sin(t * 1.5 + i) * 1 + add(ax + math.cos(a) * r, ay + 1.1, az + math.sin(a) * r, 1.0, 0.6, 1.0, 1) + end + elseif wave == 2 then + local rc = math.floor(6 * scale + 0.5) + for i = 1, rc do + local a = (i - 1) * math.pi * 2 / rc - t * 0.7 + local r = 3.2 + math.sin(t * 2 + i * 2) * 0.8 + add(ax + 17 + math.cos(a) * r, ay + 2.1, az + math.sin(a) * r, 0.9, 0.6, 0.9, 1) + end + local qc = math.floor(4 * scale + 0.5) + for i = 1, qc do + local ox = (prand(i * 31 + wave) - 0.5) * 10 + local oz = (prand(i * 57 + wave * 3) - 0.5) * 10 + if math.abs(ox) + math.abs(oz) > 4 then + add(ax + ox, ay + 1.1, az + oz, 1.0, 0.6, 1.0, 1) + end + end + elseif wave == 3 then + local sc = math.floor(8 * scale + 0.5) + for i = 1, sc do + local a = (i - 1) * math.pi * 2 / sc + t * 1.2 + local r = 3 + math.sin(t * 2.5 + i) * 1 + add(ax + 17 + math.cos(a) * r, ay + 3.4, az + 17 + math.sin(a) * r, 0.85, 0.85, 0.85, 3) + end + else + local cc = math.floor(16 * scale + 0.5) + local hubs = { { 0, 0.5, 0 }, { 17, 1.5, 0 }, { 17, 2.5, 17 }, { 0, 3.5, 17 } } + for i = 1, cc do + local hub = hubs[((i - 1) % 4) + 1] + local a = (i - 1) * math.pi * 2 / cc + t * (0.6 + (i % 3) * 0.4) + local r = 3.5 + math.sin(t * 3 + i * 1.7) * 1.5 + local kind = 1 + local h = 0.6 + if i % 5 == 0 then kind = 3 h = 0.85 end + add(ax + hub[1] + math.cos(a) * r, ay + hub[2] + h, az + hub[3] + math.sin(a) * r, + 1.0, h, 1.0, kind) + end + end + return n +end + +local function spawn_zone(i, zn) + local key = slot_key(i) + local desc = { + shape = { type = "box", half_extents = { zn.hx, zn.hy, zn.hz } }, + body_type = "kinematic", + position = { zn.x, zn.y, zn.z }, + friction = 0.7, + } + if zn.kind == 2 then desc.sensor = true end + sdk.physics.spawn(key, desc) + sdk.graphics.mesh(key .. "v", { + body = key, + scale = { zn.hx * 2, zn.hy * 2, zn.hz * 2 }, + color = zone_color(zn.kind), + }) + local s = slots[i] + s.touching = false + s.live = true + s.x, s.y, s.z, s.hx, s.hy, s.hz, s.kind = zn.x, zn.y, zn.z, zn.hx, zn.hy, zn.hz, zn.kind +end + +local function remove_zone(i) + local s = slots[i] + if not s.live then return end + local key = slot_key(i) + sdk.physics.remove(key) + sdk.graphics.remove(key .. "v") + s.live = false +end + + +local restore, phase, last_wave = nil, "idle", 0 +local function menu() + sdk.ui.menu("challenge", {title="Wipeout", section="Gamemodes", items={ + {id="start",label="Start course",enabled=phase=="idle"}, + {id="restart",label="Restart at start pad",enabled=phase~="idle"}, + {id="stop",label="Stop and return",enabled=phase~="idle"}, + }}) +end +local function clear_slots() for i=1,MAXZ do remove_zone(i) end end +local function stop(return_player) + if return_player and restore then sdk.player.teleport(restore) end + clear_slots();clear_course();restore=nil;phase="idle";run.started=false + sdk.ui.text("wo.status", "Wipeout: open Gamemodes > Wipeout to start.") + menu() +end +local function begin() + if not sdk.capabilities.player_overlap then + sdk.ui.text("wo.status","Wipeout needs the updated player-overlap API. Update the game build.");return + end + if not restore then + local p=sdk.player.read();restore={position=p.position,heading=p.heading,velocity={0,0,0}} + run.ax,run.ay,run.az=p.position[1],p.position[2]+SKY_LIFT,p.position[3] + build_course() + end + clear_slots();last_wave=0 + run.started,run.over,run.bail_used,run.hits=true,false,false,0 + run.t0=sdk.time.elapsed+2;run.grace_until=run.t0;run.hit_until=run.t0 + phase="running";teleport_to_start();menu() +end +local function finish(won,elapsed) + phase=won and "cleared" or "eliminated";run.over=true + clear_slots() + sdk.ui.text("wo.status",string.upper(phase).." at "..fmt_time(elapsed)..". Restart or Stop in Gamemodes > Wipeout.") + menu() +end +local function overlapping(key) + local body=sdk.physics.read(key) + return body and body.player_overlapping or false +end +local function fixed(ev) + local t,r=sdk.input.down("KeyT"),sdk.input.down("KeyR") + if (t and not prev.t and phase=="idle") or (r and not prev.r) then begin() end + prev.t,prev.r=t,r + if phase~="running" then return end + local now=sdk.time.elapsed + local elapsed=math.max(0,now-run.t0) + local wave=math.floor(elapsed/WAVE_LEN)+1 + local total=wave_count() + if setting("game_mode","survival")=="timed" and wave>total then finish(true,elapsed);return end + wave=(wave-1)%total+1 + if wave~=last_wave then clear_slots();last_wave=wave end + local dt=math.max(0.001,ev.dt or 1/60) + local count=gen_zones(wave,elapsed+dt,scratch) + for i=1,MAXZ do + if i<=count then + local z,s=scratch[i],slots[i] + if not s.live then spawn_zone(i,z) + else + local b=sdk.physics.read(slot_key(i)) + if b then sdk.physics.set_linvel(slot_key(i),{(z.x-b.position[1])/dt,(z.y-b.position[2])/dt,(z.z-b.position[3])/dt}) end + end + else remove_zone(i) end + end + if now>=run.grace_until then + for _,pc in ipairs(course_pieces()) do + if pc.sensor and overlapping(pc.key) then finish(false,elapsed);return end + end + local p=sdk.player.read() + if p.position[2]=run.hit_until then + run.hits=run.hits+1;run.hit_until=now+1 + if run.bail_used then finish(false,elapsed);return end + run.bail_used=true + end + s.touching=hit + end + end + local timer=setting("enable_timer",true) and (" | "..fmt_time(elapsed)) or "" + sdk.ui.text("wo.status","WIPEOUT | Wave "..wave.."/"..total..timer.." | ".. + (now Wipeout to start.") end, + on_fixed_update=fixed, + on_event=function(e) + if e.name=="world_changed" then stop(false) + elseif e.name=="menu_action" and e.menu=="challenge" then + if e.item=="start" and phase=="idle" or e.item=="restart" then begin() + elseif e.item=="stop" then stop(true) end + end + end, + on_unload=function() stop(true);sdk.ui.remove_menu("challenge");sdk.ui.text("wo.status","") end, +} diff --git a/sdk/examples/wipeout-challenge/mod.json b/sdk/examples/wipeout-challenge/mod.json new file mode 100644 index 00000000..d6d6c2b6 --- /dev/null +++ b/sdk/examples/wipeout-challenge/mod.json @@ -0,0 +1,47 @@ +{ + "id": "project-wipeout-challenge", + "api": 2, + "name": "Wipeout Challenge", + "version": "3.1.0", + "author": "opencode", + "description": "Floating platform survival with moving solid hazards, water sensors, one forgiven hit, and restart/return controls.", + "entry": "main.lua", + "settings": { + "game_mode": { + "type": "choice", + "label": "Game Mode", + "description": "Timed ends after all waves, survival loops them.", + "default": "survival", + "choices": [ + "survival", + "timed" + ] + }, + "wave_count": { + "type": "number", + "label": "Wave Count", + "description": "Number of hazard waves.", + "default": 4, + "min": 1, + "max": 10, + "step": 1 + }, + "hazard_intensity": { + "type": "choice", + "label": "Hazard Intensity", + "description": "Obstacle danger level.", + "default": "hardcore", + "choices": [ + "easy", + "normal", + "hardcore" + ] + }, + "enable_timer": { + "type": "boolean", + "label": "Enable Timer", + "description": "Show countdown timer.", + "default": true + } + } +} diff --git a/sdk/skate.lua b/sdk/skate.lua index c8ddc2c6..5726b369 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -40,6 +40,7 @@ ---@field load number ---@field relative_velocity number ---@class BodySnapshot +---@field player_overlapping boolean Enabled native local board/skater volumes touching this body; includes sensors. Solid contact margin 0.025m; sensors require intersection. Updated each fixed callback, false while suspended/attached. Geometric query only; collision masks do not suppress it. ---@field position Vec3 ---@field rotation Quat ---@field linvel Vec3 @@ -49,6 +50,11 @@ ---@field mass number ---@field speed number |linvel| ---@class PlayerSnapshot +---@field landed_trick_base string Engine-authored base label identifier (not localized), captured with landing_seq +---@field landed_spin_degrees integer Signed body rotation from the settled scorer; excludes board shuvit rotation +---@field landed_clean boolean Quality captured at the confirmed landing +---@field landed_sketchy boolean Sketchy quality captured at the confirmed landing +---@field suspended boolean local or remote simulation/visibility suspension ---@field id? string ---@field local? boolean ---@field name? string display name from the multiplayer menu @@ -273,6 +279,10 @@ function sdk.graphics.mesh_buffer_append(key, data) end function sdk.graphics.light(key, opts) end ---@return PlayerSnapshot function sdk.player.read() end +---Freeze and hide the local skater and remove their collision participation. +---False releases only this mod's suspension; unload/world change also releases it. +---@param suspended boolean +function sdk.player.suspend(suspended) end ---@return table function sdk.player.skaters() end ---@param id string|number @@ -291,7 +301,7 @@ function sdk.camera.clear_follow() end ---@param position Vec3 ---@param look_at? Vec3 function sdk.camera.set(position, look_at) end ----@param peer string|number|nil peer id to spectate, or nil to restore the native camera +---@param peer string|number|nil peer whose actual camera transform/FOV to mirror; nil restores native camera function sdk.camera.watch(peer) end ---@param key string ---@return boolean @@ -304,6 +314,11 @@ function sdk.input.pad() end ---@param key string ---@param text string function sdk.ui.text(key, text) end +---Local-only text in Pause > Multiplayer > Debug; never draws a gameplay overlay. +---Up to 8 entries per mod, 1024 UTF-8 bytes each. Empty text removes an entry. +---@param key string +---@param text string +function sdk.ui.multiplayer_debug(key, text) end ---@return NetworkInfo function sdk.net.info() end ---@return string[] @@ -377,6 +392,10 @@ function sdk.ui.remove_menu(key) end ---@field static_friction number combined contact coefficient ---@field dynamic_friction number combined contact coefficient ---@field material_tags integer[] A and B material tags +---@field id string stable body pair, shared by manifold points +---@field phase 'begin'|'stay'|'end' +---@field relative_velocity_before_solve Vec3 A minus B at contact point, including angular velocity +---@field closing_speed number m/s; zero on end ---@class NativeJoint ---@field index integer zero-based native joint ID (use this, not Lua array index) ---@field name string native authored joint name @@ -388,19 +407,148 @@ function sdk.ui.remove_menu(key) end ---@field free_twist boolean ---@field drive_enabled boolean false when a mod suppresses the child's animation drives ---@field override_owner? string +---@field enabled boolean +---@field possession_enabled boolean +---@field load? NativeJointLoad most recent eligible solved row +---@field parameters integer[] native words, read-only +---@field frames integer[] native words, read-only ---@return {tick:integer,dt:number,contacts:NativeContact[],joints:NativeJoint[],parts:table[],ragdoll:boolean,partial_ragdoll:boolean} local player only function sdk.player.physics() end ----@return NativeContact[] most recent solved frame, max 128 reports +---@return NativeContact[] most recent solved frame, max 128 active plus end reports to reach 256 function sdk.player.contacts() end ---@return NativeJoint[] function sdk.player.joints() end ---@return {index:integer,position:Vec3,velocity:Vec3,angvel:Vec3,inverse_mass:number}[] function sdk.player.parts() end ---@param joint integer zero-based index from sdk.player.joints() ----@param options {swing_limit?:number,twist_limit?:number,free_swing?:boolean,free_twist?:boolean,drive_enabled?:boolean} angles [0.01,pi], radians +---@param options JointOverride angles [0.01,pi], radians ---Replaces this mod's override; omitted properties follow native state. Another mod cannot take an owned joint. function sdk.player.set_joint(joint, options) end ---@param joint integer restores this mod's joint override function sdk.player.reset_joint(joint) end ----Restores all joint overrides owned by this mod. +---Restores all joint AND part overrides owned by this mod. function sdk.player.reset_joints() end + +-- General engine access: see GENERAL_API.md for units, lifecycle and limitations. +sdk.commands = {} +---@class CommandResult +---@field token integer latest request token for this key +---@field ok boolean host execution succeeded; not remote acknowledgement +---@field error? string +---@field value? any catalog for engine_inspect, otherwise nil +---@field tick integer +---@param key string at most 64 result keys per mod +---@param command table validated native command; no nested request +---@return integer token +function sdk.commands.request(key, command) end +---@param key string +---@return CommandResult|nil nil until the latest token is observed +function sdk.commands.result(key) end + +sdk.engine = {version=1} +---@return string[] +function sdk.engine.systems() end +---@param system string player, rig, bodies, input, graphs, animation, scoring, world, camera, network, commands +---@return table|nil latest snapshot +function sdk.engine.read(system) end +---@param key string command result key +---@param system 'graphs'|'scoring' +function sdk.engine.inspect(key, system) end + +---@class BodyReference +---@field kind 'skater'|'board'|'mod' +---@field index? integer zero-based native physical body ID +---@field key? string mod-owned Rapier body key +---@class NativeBody: BodyReference +---@field position Vec3 world centre of mass +---@field rotation Quat XYZW +---@field velocity Vec3 world m/s +---@field angvel Vec3 world rad/s +---@field inverse_mass number +---@field inverse_inertia Vec3[] world matrix columns +---@field state_flags integer effective native solve state +---@field name? string mapped animation bone name +---@field joint? integer native joint whose child is this body +---@field collision_enabled? boolean effective enabled flag +---@field animation_drives? boolean permits native animation drives +---@field possession_drives? boolean permits native board-holding drives +---@field material? {static_friction:number,dynamic_friction:number} +---@field override? {owner:string,options:PartOverride} +sdk.bodies = {} +---@param ref BodyReference +---@return NativeBody|table|nil +function sdk.bodies.read(ref) end +---@param ref BodyReference +---@param value Vec3 world N s +---@param point? Vec3 world point; omitted means centre of mass +function sdk.bodies.impulse(ref, value, point) end +---@param ref BodyReference +---@param value Vec3 world N m s +function sdk.bodies.angular_impulse(ref, value) end + +---@class JointOverride +---@field swing_limit? number [0.01,pi] radians +---@field twist_limit? number [0.01,pi] radians +---@field free_swing? boolean +---@field free_twist? boolean +---@field enabled? boolean false removes entire constraint including linear attachment +---@field drive_enabled? boolean false suppresses child animation drives +---@field possession_enabled? boolean false suppresses child board-holding drives +---@field descendants? boolean extends drive suppressions to child subtree +---@class PartOverride +---@field motion? 'dynamic'|'frozen'|'static' solve flags 4/2/1; not a player state change +---@field collision? boolean +---@field friction? number [0,10], both material coefficients +---@field animation_drives? boolean +---@field possession_drives? boolean +---@class NativeJointLoad +---@field linear_impulse Vec3 world N s on child/A +---@field angular_impulse Vec3 world N m s, direct angular couple +---@field force Vec3 N +---@field torque Vec3 N m, excluding linear anchor lever-arm contribution +---@field solver_words integer[] packed native u32 values +---@class NativeRig +---@field tick integer completed solve cursor +---@field dt number seconds +---@field parts NativeBody[] +---@field board NativeBody[] +---@field joints NativeJoint[] +---@field contacts NativeContact[] at most 128 active, plus end reports to reach 256 +---@field contacts_truncated boolean observation rows were omitted; pair tracking continues +---@field ragdoll boolean native mode +---@field partial_ragdoll boolean native mode +sdk.rig = {} +---@return NativeRig +---@param fields? string[] Optional top-level rig fields; omit for the complete rig. +function sdk.rig.read(fields) end +---@return NativeBody[] +function sdk.rig.parts() end +---@return NativeJoint[] +function sdk.rig.joints() end +---@return NativeContact[] +function sdk.rig.contacts() end +---@param index integer native joint ID, not a Lua array index +---@param options JointOverride replacement override; omitted fields follow native settings +function sdk.rig.configure_joint(index, options) end +---@param index integer native joint ID +function sdk.rig.reset_joint(index) end +---@param index integer physical skater part ID +---@param options PartOverride +function sdk.rig.configure_part(index, options) end +---@param index integer physical skater part ID +function sdk.rig.reset_part(index) end +---Restores all joint AND part overrides owned by this mod. +function sdk.rig.reset() end + +---@param id integer mapped gameplay action 64..81 +---@param value? number [-1,1]; nil restores normal input +function sdk.input.override_action(id, value) end +sdk.graphs = {} +---@param graph 'action'|'motion' +---@return {current?:integer,previous?:integer,name?:string,dt:number,state_times:table,active_behaviors:integer[]}|nil +function sdk.graphs.read(graph) end +---@param graph 'action'|'motion' +---@param target 'state'|'transition'|'behavior' +---@param index integer runtime ID from the catalog; zero-based +---@param enabled? boolean nil restores the original gate +function sdk.graphs.set_enabled(graph, target, index, enabled) end