From 203aeaf8bbc3df7b93f2fd724115f347e1df6af3 Mon Sep 17 00:00:00 2001 From: chazmm Date: Mon, 7 Sep 2026 22:52:57 +0100 Subject: [PATCH] Merge supplied skating controls and graph handoffs, preserve current offboard --- crates/skate-core/src/input/controller.rs | 2 +- crates/skate-core/src/input/gameplay_map.rs | 11 + crates/skate-core/src/input/mod.rs | 2 + .../skate-core/src/input/riding_intentions.rs | 26 +- .../src/input/riding_intentions_tests.rs | 33 ++ .../src/input/steering_intentions.rs | 26 ++ crates/skate-core/src/input/tick.rs | 36 +++ .../skate-core/src/input/trick_intentions.rs | 133 ++++++++ crates/skate-core/src/physics/mod.rs | 1 + crates/skate-core/src/physics/phase.rs | 106 ++++++ .../skate-core/src/player/input_phase/mod.rs | 2 + .../src/player/input_phase/motion_math.rs | 3 +- .../src/player/input_phase/requests.rs | 23 ++ .../src/player/input_phase/runtime.rs | 4 +- .../src/player/input_phase/types.rs | 47 +-- crates/skate-core/tests/tailwalk_input.rs | 59 ++++ crates/skate-game/src/camera.rs | 6 +- crates/skate-game/src/camera/graph.rs | 26 +- .../skate-game/src/camera/graph_conditions.rs | 10 +- crates/skate-game/src/camera/publication.rs | 36 +++ crates/skate-game/src/camera/runtime.rs | 27 +- crates/skate-game/src/camera/subject.rs | 30 +- crates/skate-game/src/graph_host/action.rs | 105 +++++- .../src/graph_host/action_conditions.rs | 2 +- .../skate-game/src/graph_host/action_nodes.rs | 12 +- crates/skate-game/src/graph_host/mod.rs | 3 + crates/skate-game/src/graph_host/motion.rs | 39 ++- .../src/graph_host/motion_animation.rs | 16 + .../src/graph_host/motion_channels.rs | 11 + .../graph_host/motion_character_gesture.rs | 9 + .../src/graph_host/motion_conditions.rs | 40 ++- .../src/graph_host/motion_execute.rs | 7 + .../graph_host/motion_gameplay_conditions.rs | 4 +- .../src/graph_host/motion_native.rs | 1 + .../skate-game/src/graph_host/motion_nodes.rs | 6 + .../src/graph_host/motion_stock_execute.rs | 88 +++++ .../src/graph_host/motion_stock_gameplay.rs | 191 +++++++++++ crates/skate-game/src/graph_host/outputs.rs | 305 ++++++++++++++++++ crates/skate-game/src/input.rs | 21 +- crates/skate-game/src/input/controllers.rs | 20 ++ .../skate-game/src/input/gesture_catalog.rs | 5 +- .../skate-game/src/input/gesture_mapping.rs | 12 +- .../skate-game/src/input/tests/controllers.rs | 34 ++ crates/skate-game/src/physics.rs | 96 +++++- .../skate-game/src/physics/animation_phase.rs | 4 +- .../skate-game/src/physics/camera_output.rs | 14 +- crates/skate-game/src/physics/controls.rs | 11 +- crates/skate-game/src/physics/frame.rs | 47 ++- .../src/physics/player_input/mod.rs | 3 + crates/skate-game/src/physics/player_state.rs | 2 +- .../src/physics/player_state/selection.rs | 9 +- crates/skate-game/src/skater_animation.rs | 34 +- crates/skate-game/src/tests/action_host.rs | 40 ++- crates/skate-game/src/tests/air_playback.rs | 94 +++++- .../src/tests/powerslide_playback.rs | 103 ++++++ docs/archive-crates-3-merge.md | 77 +++++ 56 files changed, 1987 insertions(+), 127 deletions(-) create mode 100644 crates/skate-core/src/input/tick.rs create mode 100644 crates/skate-core/src/input/trick_intentions.rs create mode 100644 crates/skate-core/src/physics/phase.rs create mode 100644 crates/skate-core/src/player/input_phase/requests.rs create mode 100644 crates/skate-core/tests/tailwalk_input.rs create mode 100644 crates/skate-game/src/graph_host/motion_stock_execute.rs create mode 100644 crates/skate-game/src/graph_host/motion_stock_gameplay.rs create mode 100644 crates/skate-game/src/graph_host/outputs.rs create mode 100644 crates/skate-game/src/tests/powerslide_playback.rs create mode 100644 docs/archive-crates-3-merge.md diff --git a/crates/skate-core/src/input/controller.rs b/crates/skate-core/src/input/controller.rs index 27e1b8ab..15cb448d 100644 --- a/crates/skate-core/src/input/controller.rs +++ b/crates/skate-core/src/input/controller.rs @@ -152,7 +152,7 @@ mod startup_tests; /// Independent reciprocal-square-root estimate followed by the original two /// refinement steps. Host arithmetic is not claimed to be bit-exact Xenon math. -pub(super) fn magnitude(squared: f32) -> f32 { +pub fn magnitude(squared: f32) -> f32 { let mut inverse = estimate(squared); for _ in 0..2 { let correction = (-squared).mul_add(inverse * inverse, 1.0); diff --git a/crates/skate-core/src/input/gameplay_map.rs b/crates/skate-core/src/input/gameplay_map.rs index 84ecfb38..71ea9cd4 100644 --- a/crates/skate-core/src/input/gameplay_map.rs +++ b/crates/skate-core/src/input/gameplay_map.rs @@ -2,9 +2,20 @@ //! Config identity and VM operand semantics are recorded in STEERING_INPUT.md. use super::{controller::ActionMap, pad::Pad}; +#[derive(Clone, Copy, Debug, PartialEq)] pub struct GameplayActions { values: [f32; 18], } + +impl GameplayActions { + pub fn from_values(values: [f32; 18]) -> Self { + Self { values } + } + + pub fn values(&self) -> &[f32; 18] { + &self.values + } +} impl GameplayActions { /// The native pad retains storage even while count is zero. With a nonzero /// count, stock Button operands read their fixed index, without a per-index diff --git a/crates/skate-core/src/input/mod.rs b/crates/skate-core/src/input/mod.rs index 7cdae645..d74a089a 100644 --- a/crates/skate-core/src/input/mod.rs +++ b/crates/skate-core/src/input/mod.rs @@ -5,12 +5,14 @@ pub mod anticipation_intentions; pub mod animation_packet; pub mod controller; pub mod gameplay_map; +pub mod tick; pub mod graph_intents; pub mod grind_intentions; pub mod history; pub mod pad; pub mod power_sliding; pub mod riding_intentions; +pub mod trick_intentions; pub mod set_turning; pub mod steering_intentions; pub mod turn_conditioner; diff --git a/crates/skate-core/src/input/riding_intentions.rs b/crates/skate-core/src/input/riding_intentions.rs index 33458465..8773b242 100644 --- a/crates/skate-core/src/input/riding_intentions.rs +++ b/crates/skate-core/src/input/riding_intentions.rs @@ -1,5 +1,5 @@ -//! Push, brake, body-spin, steering, kick-turn and crouch from TU3 Fill825999F0. -//! Trick/gesture, slide, manual and offboard branches remain separate work. +//! Push, brake, body-spin, steering, kick-turn, crouch and powerslide intents +//! from the TU3 ActionGraph input listener (`825999F0`). use super::{ angle::left_stick_angle, controller::{DerivedControllerInput, magnitude}, @@ -64,7 +64,7 @@ pub fn produce( new_right = false; new_left = false; } - let mut output = Vec::with_capacity(13); + let mut output = Vec::with_capacity(17); let mut emit = |name, value| output.push(RidingIntent { name, value }); if current & (1 << 20) != 0 && actor_flags & (1 << 7) == 0 { emit("Brake", 1.0); @@ -131,6 +131,26 @@ pub fn produce( if let Some(value) = steering.turn { emit("Turn", value); } + + //8259A430..A50C: start queries and continuous values have distinct open + //heading windows. Both continuous descriptors can be present together. + //8259AECC..AF5C gates only actor bit8, independently of push inhibition. + if actor_flags & (1 << 8) == 0 && length > 0.89999998 { + const SLIDE_SCALE: f32 = 0.28004956; + const HALF_PI: f32 = f32::from_bits(0x3fc90fdb); + if angle > 0.0 && angle < 0.91000003 { + emit("RightSlideStart", 1.0); + } + if angle > -0.91000003 && angle < 0.0 { + emit("LeftSlideStart", 1.0); + } + if angle > -2.0 && angle < HALF_PI { + emit("LeftSlide", -((angle + 2.0) * SLIDE_SCALE)); + } + if angle > -HALF_PI && angle < 2.0 { + emit("RightSlide", (2.0 - angle) * SLIDE_SCALE); + } + } output } diff --git a/crates/skate-core/src/input/riding_intentions_tests.rs b/crates/skate-core/src/input/riding_intentions_tests.rs index 170c93d8..ac57dd57 100644 --- a/crates/skate-core/src/input/riding_intentions_tests.rs +++ b/crates/skate-core/src/input/riding_intentions_tests.rs @@ -86,3 +86,36 @@ fn dual_push_gate_requires_both_feet_and_brake_uses_actor_mode() { ); assert!(produce(&brake, 1 << 7, PushPreferences::default()).is_empty()); } + +#[test] +fn powerslide_queries_are_separate_from_signed_continuous_values() { + let intents = |x: f32, y: f32, flags| { + let mut words = [0; 26]; + words[7] = x.to_bits(); + words[8] = y.to_bits(); + produce(&DerivedControllerInput::from_words(words), flags, PushPreferences::default()) + }; + //8259A430..A50C: lateral steering is outside both start windows. + for x in [-1.0, 1.0] { + let values = intents(x, 0.0, 0); + assert!(!values.iter().any(|i| i.name.ends_with("SlideStart"))); + assert!(values.iter().any(|i| i.name == "Turn")); + } + //Diagonal rearward queries have independent, overlapping continuous values. + for (x, start) in [(0.6, "RightSlideStart"), (-0.6, "LeftSlideStart")] { + let values = intents(x, -0.8, 0); + assert!(values.iter().any(|i| i.name == start && i.value == 1.0)); + assert!(values.iter().any(|i| i.name == "LeftSlide" && i.value < 0.0)); + assert!(values.iter().any(|i| i.name == "RightSlide" && i.value > 0.0)); + let inhibited_push = intents(x, -0.8, 1 << 11); + for value in values.iter().filter(|i| i.name.contains("Slide")) { + assert!(inhibited_push.contains(value)); + } + assert!(!intents(x, -0.8, 1 << 8).iter().any(|i| i.name.contains("Slide"))); + } + for (x, y) in [(0.0, 0.0), (0.0, 1.0), (0.0, -1.0), (0.3, -0.4)] { + let values = intents(x, y, 0); + assert!(!values.iter().any(|i| i.name.ends_with("SlideStart"))); + } + assert!(!intents(0.3, -0.4, 0).iter().any(|i| i.name.contains("Slide"))); +} diff --git a/crates/skate-core/src/input/steering_intentions.rs b/crates/skate-core/src/input/steering_intentions.rs index ac8f3cb4..c7bc3afb 100644 --- a/crates/skate-core/src/input/steering_intentions.rs +++ b/crates/skate-core/src/input/steering_intentions.rs @@ -47,3 +47,29 @@ pub fn produce(left: [f32; 2], actor_flags_1908: u32) -> SteeringIntentions { hard_turn_crouch: (active && allowed).then_some((hard * f32::from_bits(0x3f59999a)).abs()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn left_steering_preserves_signed_turn_and_hard_turn_symmetry() { + let left = produce([-0.75, 0.0], 0); + let right = produce([0.75, 0.0], 0); + + assert_eq!(left.turn, Some(-0.75)); + assert_eq!(right.turn, Some(0.75)); + assert_eq!(left.hard_turn, right.hard_turn.map(|value| -value)); + assert_eq!(left.hard_turn_crouch, right.hard_turn_crouch); + } + + #[test] + fn steering_gate_suppresses_all_three_emissions_without_changing_input() { + let gated = produce([-0.75, 0.0], 1); + assert_eq!(gated, SteeringIntentions { + turn: None, + hard_turn: None, + hard_turn_crouch: None, + }); + } +} diff --git a/crates/skate-core/src/input/tick.rs b/crates/skate-core/src/input/tick.rs new file mode 100644 index 00000000..9a887e8a --- /dev/null +++ b/crates/skate-core/src/input/tick.rs @@ -0,0 +1,36 @@ +//! Immutable fixed-tick input boundary. +//! +//! The platform adapter owns device polling. Once a `TickInput` exists, all +//! simulation phases consume this value; they do not access a platform device, +//! pad history, or rebuild the gameplay map. + +use super::gameplay_map::GameplayActions; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TickInput { + tick: u64, + actions: GameplayActions, + controller_available: bool, +} + +impl TickInput { + pub const fn new(tick: u64, actions: GameplayActions, controller_available: bool) -> Self { + Self { + tick, + actions, + controller_available, + } + } + + pub const fn tick(self) -> u64 { + self.tick + } + + pub const fn actions(self) -> GameplayActions { + self.actions + } + + pub const fn controller_available(self) -> bool { + self.controller_available + } +} diff --git a/crates/skate-core/src/input/trick_intentions.rs b/crates/skate-core/src/input/trick_intentions.rs new file mode 100644 index 00000000..8027576d --- /dev/null +++ b/crates/skate-core/src/input/trick_intentions.rs @@ -0,0 +1,133 @@ +//! Player grab and aerial trick-control intents. +//! +//! This is the controller half of the TU3 `ActionGraphInputListener::Fill` +//! path. The listener publishes both grab variants every tick; the authored +//! ActionGraph/MotionGraph decides which one is legal for the current stance. + +use super::controller::DerivedControllerInput; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TrickIntent { + pub name: &'static str, + pub value: f32, +} + +/// Recovered from `ActionGraphInputListener::Fill` (`825fb4c8`/`825999f0`): +/// ground grabs use exact trigger values, while air grabs use positive trigger +/// values. Dark-catch requests come from the packed action flags, not from +/// gesture timing. +pub fn produce(controller: &DerivedControllerInput) -> Vec { + let words = controller.words(); + let left_trigger = f32::from_bits(words[11]); + let right_trigger = f32::from_bits(words[12]); + let right_x = f32::from_bits(words[9]); + let right_y = f32::from_bits(words[10]); + + // Fill825992D8 advances the grab timers only when the trigger action is + // exactly 1.0; partial analog values are not a held grab in Skate 3. + let left_ground = left_trigger == 1.0; + let right_ground = right_trigger == 1.0; + let left_air = left_trigger > 0.0; + let right_air = right_trigger > 0.0; + + let mut output = Vec::with_capacity(12); + let mut emit = |name, value| output.push(TrickIntent { name, value }); + if left_ground { + emit("LeftGroundGrab", 1.0); + } + if left_air { + emit("LeftAirGrab", 1.0); + } + if right_ground { + emit("RightGroundGrab", 1.0); + } + if right_air { + emit("RightAirGrab", 1.0); + } + + let previous_flags = words[6]; + let current_flags = words[13]; + // TU3 Fill8259A9E4..AA54: B publishes Dismount while held and + // NewDismount only on its rising edge. No grab/state/actor gate here; + // the authored graphs decide whether this means dismount or no-foot air. + // Names: 82F84B20 -> 830BE378, 82F84B08 -> 830C07AC. + let dismount_button = 1 << 20; + if current_flags & dismount_button != 0 { + emit("Dismount", 1.0); + if previous_flags & dismount_button == 0 { + emit("NewDismount", 1.0); + } + } + let dark_flags = (1 << 20) | (1 << 28); + if current_flags & dark_flags != 0 { + emit("DarkCatch", 1.0); + } + if current_flags & dark_flags != 0 && previous_flags & dark_flags == 0 { + emit("NewDarkCatch", 1.0); + } + // Fill825999F0 publishes the board-adjust pair from the same normalized + // stick vector used by the authored air board-adjust states. The native + // 82599B08..BB4: v125 = -RStickY is the denominator and v126 = + // RStickX is the numerator. The quadrant correction also tests -RStickY. + // This is atan2(RStickX, -RStickY), in that argument order. + let board_adjust_magnitude = (right_x.mul_add(right_x, right_y * right_y)).sqrt(); + if board_adjust_magnitude > 0.0 { + emit("BoardAdjustAngle", right_x.atan2(-right_y)); + emit("BoardAdjustMag", board_adjust_magnitude); + } + if right_x != 0.0 { + emit("TweakX", right_x); + } + if right_y != 0.0 { + emit("TweakY", right_y); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn controller(previous_left: f32, previous_right: f32, left: f32, right: f32) -> DerivedControllerInput { + let mut words = [0; 26]; + words[4] = previous_left.to_bits(); + words[5] = previous_right.to_bits(); + words[11] = left.to_bits(); + words[12] = right.to_bits(); + words[9] = 0.25f32.to_bits(); + words[10] = (-0.5f32).to_bits(); + DerivedControllerInput::from_words(words) + } + + #[test] + fn publishes_holds_edges_and_tweaks() { + let output = produce(&controller(0.0, 0.25, 1.0, 0.25)); + assert!(output.iter().any(|i| i.name == "LeftAirGrab")); + assert!(!output.iter().any(|i| i.name == "NewLeftAirGrab")); + assert!(!output.iter().any(|i| i.name == "NewRightAirGrab")); + assert_eq!(output.iter().find(|i| i.name == "TweakX").unwrap().value, 0.25); + assert_eq!(output.iter().find(|i| i.name == "TweakY").unwrap().value, -0.5); + let angle = output.iter().find(|i| i.name == "BoardAdjustAngle").unwrap().value; + let magnitude = output.iter().find(|i| i.name == "BoardAdjustMag").unwrap().value; + assert!((angle - 0.4636476).abs() < 0.00001); + assert!((magnitude - 0.559017).abs() < 0.00001); + } + + #[test] + fn board_adjust_cardinals_follow_fill_register_order() { + use std::f32::consts::{FRAC_PI_2, PI}; + for (x, y, expected) in [ + (0.0f32, -1.0f32, 0.0), + (1.0, 0.0, FRAC_PI_2), + (0.0, 1.0, PI), + (-1.0, 0.0, -FRAC_PI_2), + ] { + let mut words = [0; 26]; + words[9] = x.to_bits(); + words[10] = y.to_bits(); + let output = produce(&DerivedControllerInput::from_words(words)); + let angle = output.iter().find(|i| i.name == "BoardAdjustAngle").unwrap().value; + assert!((angle - expected).abs() < 0.00001, "x={x}, y={y}, angle={angle}"); + } + } +} diff --git a/crates/skate-core/src/physics/mod.rs b/crates/skate-core/src/physics/mod.rs index 3307ca81..3d3cab68 100644 --- a/crates/skate-core/src/physics/mod.rs +++ b/crates/skate-core/src/physics/mod.rs @@ -33,6 +33,7 @@ pub mod joint_solver; pub mod manual; pub mod mass; pub(crate) mod native_arithmetic; +pub mod phase; pub mod point_force; pub(crate) mod reciprocal_sqrt; pub mod rigid_body; diff --git a/crates/skate-core/src/physics/phase.rs b/crates/skate-core/src/physics/phase.rs new file mode 100644 index 00000000..af64dd17 --- /dev/null +++ b/crates/skate-core/src/physics/phase.rs @@ -0,0 +1,106 @@ +//! Engine-independent transport records for one authoritative simulation tick. +//! +//! These records deliberately contain requests and published values rather +//! than references to a game/world runtime. Game code owns when they are +//! consumed; core code owns their shape and deterministic validation. + +use crate::{math::Vector3, player::state::PhysicalStateId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PhysicsBody { Board, Rider } + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PhysicsCommand { + SetVelocity { body: PhysicsBody, linear: Vector3, angular: Vector3 }, + ApplyImpulse { body: PhysicsBody, impulse: Vector3, point: Vector3 }, + RequestState(PhysicalStateId), + SetContactMode { body: PhysicsBody, enabled: bool }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicsCommandBuffer { tick: u64, commands: Vec } + +impl PhysicsCommandBuffer { + pub fn new(tick: u64) -> Self { Self { tick, commands: Vec::new() } } + pub fn tick(&self) -> u64 { self.tick } + pub fn commands(&self) -> &[PhysicsCommand] { &self.commands } + pub fn is_empty(&self) -> bool { self.commands.is_empty() } + + pub fn push(&mut self, tick: u64, command: PhysicsCommand) -> Result<(), String> { + if tick != self.tick { + return Err(format!("Physics command belongs to tick {tick}, buffer owns tick {}", self.tick)); + } + self.commands.push(command); + Ok(()) + } + + pub fn clear(&mut self, tick: u64) -> Result<(), String> { + if tick != self.tick { + return Err(format!("Cannot clear physics commands for tick {tick}; buffer owns tick {}", self.tick)); + } + self.commands.clear(); + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PhysicsEvent { + StateChanged { from: PhysicalStateId, to: PhysicalStateId }, + Landing, + Wipeout, + Contact { body: PhysicsBody }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicsEventBuffer { tick: u64, events: Vec } + +impl PhysicsEventBuffer { + pub fn new(tick: u64) -> Self { Self { tick, events: Vec::new() } } + pub fn tick(&self) -> u64 { self.tick } + pub fn events(&self) -> &[PhysicsEvent] { &self.events } + + pub fn emit(&mut self, tick: u64, event: PhysicsEvent) -> Result<(), String> { + if tick != self.tick { + return Err(format!("Physics event belongs to tick {tick}, buffer owns tick {}", self.tick)); + } + self.events.push(event); + Ok(()) + } +} + +/// Compact immutable publication consumed by animation, camera, rendering, +/// feedback and UI. Detailed native records remain owned by their subsystems; +/// this is the stable cross-phase summary. +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicalOutputSnapshot { + pub tick: u64, + pub state: PhysicalStateId, + pub board_position: Vector3, + pub board_linear_velocity: Vector3, + pub rider_root_position: Vector3, + pub rider_linear_velocity: Vector3, + /// Ground/contact results are published once by the solver and must not + /// be reconstructed by camera, animation, or feedback consumers. + pub ground_normal: Vector3, + pub contact_count: u32, + pub predicted_position: Vector3, + pub grounded: bool, + pub wiping_out: bool, + pub landed: bool, + pub events: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_and_event_buffers_reject_stale_ticks() { + let mut commands = PhysicsCommandBuffer::new(4); + assert!(commands.push(3, PhysicsCommand::RequestState(PhysicalStateId::PhysicsGround)).is_err()); + assert!(commands.push(4, PhysicsCommand::RequestState(PhysicalStateId::PhysicsGround)).is_ok()); + let mut events = PhysicsEventBuffer::new(4); + assert!(events.emit(5, PhysicsEvent::Landing).is_err()); + assert!(events.emit(4, PhysicsEvent::Landing).is_ok()); + } +} diff --git a/crates/skate-core/src/player/input_phase/mod.rs b/crates/skate-core/src/player/input_phase/mod.rs index 04ae05b4..acc0c73f 100644 --- a/crates/skate-core/src/player/input_phase/mod.rs +++ b/crates/skate-core/src/player/input_phase/mod.rs @@ -10,11 +10,13 @@ mod runtime; mod types; mod air_output; mod pose_output; +mod requests; pub use runtime::{InputContinuation, InputPhaseError, InputPhaseServices, process_input, start_input, finish_input}; pub use types::*; pub use air_output::AirOutputFields; pub use pose_output::{AnimationOutputFields, ScoringOutputFields, SkeletonOutputFields}; +pub use requests::*; #[cfg(test)] mod tests; diff --git a/crates/skate-core/src/player/input_phase/motion_math.rs b/crates/skate-core/src/player/input_phase/motion_math.rs index 5ed810cd..6c1b7899 100644 --- a/crates/skate-core/src/player/input_phase/motion_math.rs +++ b/crates/skate-core/src/player/input_phase/motion_math.rs @@ -1,6 +1,7 @@ //! Inlined physical input calculations from TU3 82DB4048. //! Xenon estimates use the shared, explicitly approximation-derived model. -use super::types::{GroundHistoryRequest, GroundHistoryResult, PrepareJumpRequest, RawVector}; +use super::requests::{GroundHistoryRequest, GroundHistoryResult, PrepareJumpRequest}; +use super::types::RawVector; use crate::physics::{ board_motion_output::inverse_length_squared, native_arithmetic::{dot3, reciprocal_estimate}, diff --git a/crates/skate-core/src/player/input_phase/requests.rs b/crates/skate-core/src/player/input_phase/requests.rs new file mode 100644 index 00000000..0b1b71a5 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/requests.rs @@ -0,0 +1,23 @@ +use super::types::RawVector; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundHistoryRequest { + pub previous_position: RawVector, + pub current_position: RawVector, + pub previous_filtered_delta: RawVector, + /// Processed +2604. The native block also consumes constants at + /// `0x82072818` and `0x8231A844`; the service owns those exact values. + pub timestep_bits: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundHistoryResult { + pub delta: RawVector, + pub filtered_delta: RawVector, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PrepareJumpRequest { + pub skateboard_vector: RawVector, + pub previous_velocity: RawVector, +} diff --git a/crates/skate-core/src/player/input_phase/runtime.rs b/crates/skate-core/src/player/input_phase/runtime.rs index f7bbdf0a..2752968e 100644 --- a/crates/skate-core/src/player/input_phase/runtime.rs +++ b/crates/skate-core/src/player/input_phase/runtime.rs @@ -5,9 +5,9 @@ use super::publication::{ transfer_bit, }; use super::types::{ - AnimationInputPacket, GroundHistoryRequest, PhysicalPlayerInput, PlayerInputState, - PrepareJumpRequest, ProcessedPhysicsInput, RawVector, + AnimationInputPacket, PhysicalPlayerInput, PlayerInputState, ProcessedPhysicsInput, RawVector, }; +use super::requests::{GroundHistoryRequest, PrepareJumpRequest}; const FIXED_FRAME_STEP: f32 = f32::from_bits(0x3c88_8889); diff --git a/crates/skate-core/src/player/input_phase/types.rs b/crates/skate-core/src/player/input_phase/types.rs index c4e276df..f3dd765e 100644 --- a/crates/skate-core/src/player/input_phase/types.rs +++ b/crates/skate-core/src/player/input_phase/types.rs @@ -397,6 +397,22 @@ pub struct ProcessedPhysicsInput { pub actor_query_2952: u32, } +/// Immutable publication of the completed physical-input phase for one tick. +/// The mutable record remains private to the input producer; selectors and +/// later phases consume this value object so they cannot observe a half-written +/// packet. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProcessedPhysicsSnapshot { + pub tick: u64, + pub input: ProcessedPhysicsInput, +} + +impl ProcessedPhysicsSnapshot { + pub const fn new(tick: u64, input: ProcessedPhysicsInput) -> Self { + Self { tick, input } + } +} + impl Default for ProcessedPhysicsInput { fn default() -> Self { Self { @@ -479,26 +495,21 @@ impl Default for ProcessedPhysicsInput { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct GroundHistoryRequest { - pub previous_position: RawVector, - pub current_position: RawVector, - pub previous_filtered_delta: RawVector, - /// Processed +2604. The native block also consumes constants at - /// `0x82072818` and `0x8231A844`; the service owns those exact values. - pub timestep_bits: u32, -} +#[cfg(test)] +mod snapshot_tests { + use super::*; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct GroundHistoryResult { - pub delta: RawVector, - pub filtered_delta: RawVector, -} + #[test] + fn processed_snapshot_is_immutable_and_tick_owned() { + let mut input = ProcessedPhysicsInput::default(); + input.flags_2468 = 0x10; + let snapshot = ProcessedPhysicsSnapshot::new(23, input); + input.flags_2468 = 0x20; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PrepareJumpRequest { - pub skateboard_vector: RawVector, - pub previous_velocity: RawVector, + assert_eq!(snapshot.tick, 23); + assert_eq!(snapshot.input.flags_2468, 0x10); + assert_eq!(input.flags_2468, 0x20); + } } pub(crate) fn empty_external_physics() -> ExternalPhysicsInput { diff --git a/crates/skate-core/tests/tailwalk_input.rs b/crates/skate-core/tests/tailwalk_input.rs new file mode 100644 index 00000000..90a9492c --- /dev/null +++ b/crates/skate-core/tests/tailwalk_input.rs @@ -0,0 +1,59 @@ +//! Input-publication check; does not substitute for the full airborne replay. +use skate_core::input::{controller::{DerivedControllerInput, MagnitudeHeldSettings}, + gameplay_map::GameplayActions, pad::Pad, riding_intentions::{self, PushPreferences}, + trick_intentions, xbox::{self, XboxState}}; + +#[test] +fn alternate_double_push_route_publishes_required_action_graph_inputs() { + let mut pad = Pad::new(); + let mut controller = DerivedControllerInput::from_words([0; 26]); + controller.initialize(); + let settings = MagnitudeHeldSettings { attribute: Some(0.5), missing_attribute_value: 0.5 }; + for buttons in [0, 0x5000] { + pad.update(&xbox::convert(&XboxState { + buttons, triggers: [255, 0], left: [0, 0], right: [0, 32767], + }, 0)); + controller.update(&mut GameplayActions::from_pad(&pad), 1./60., false, false, &settings); + let trick = trick_intentions::produce(&controller); + assert!(trick.iter().any(|i| i.name == "LeftAirGrab")); + assert!(trick.iter().any(|i| i.name == "BoardAdjustMag" && i.value > 0.)); + assert!(trick.iter().any(|i| i.name == "BoardAdjustAngle" && i.value.abs() >= 2.355)); + let push = riding_intentions::produce(&controller, 0, PushPreferences::default()); + for name in ["LeftPush", "RightPush"] { + assert_eq!(push.iter().any(|i| i.name == name), buttons == 0x5000); + } + } + // The actor's double-push suppression remains a live graph dependency. + let blocked = riding_intentions::produce(&controller, 1 << 6, PushPreferences::default()); + assert!(!blocked.iter().any(|i| matches!(i.name, "LeftPush" | "RightPush"))); +} + +#[test] +fn tailwalk_b_button_publishes_dismount_hold_and_press_edge() { + let mut pad = Pad::new(); + pad.update(&xbox::convert(&XboxState { + buttons: 0x2000, triggers: [255, 0], left: [0, 0], right: [0, 32767], + }, 0)); + let mut controller = DerivedControllerInput::from_words([0; 26]); + controller.initialize(); + controller.update(&mut GameplayActions::from_pad(&pad), 1./60., false, false, + &MagnitudeHeldSettings { attribute: Some(0.5), missing_attribute_value: 0.5 }); + // TU3 Fill8259AA20..AA34 publishes Dismount when this bit is set. + assert_ne!(controller.words()[13] & (1 << 20), 0); + let trick = trick_intentions::produce(&controller); + let riding = riding_intentions::produce(&controller, 0, PushPreferences::default()); + assert!(trick.iter().any(|i| i.name == "Dismount" && i.value == 1.)); + assert!(trick.iter().any(|i| i.name == "NewDismount" && i.value == 1.)); + assert!(!riding.iter().any(|i| i.name == "Dismount")); + assert!(!riding.iter().any(|i| matches!(i.name, "LeftPush" | "RightPush"))); + for (buttons, held, edge) in [(0x2000, true, false), (0, false, false), (0x2000, true, true), (0x0200, false, false)] { + pad.update(&xbox::convert(&XboxState { + buttons, triggers: [255, 0], left: [0, 0], right: [0, 32767], + }, 0)); + controller.update(&mut GameplayActions::from_pad(&pad), 1./60., false, false, + &MagnitudeHeldSettings { attribute: Some(0.5), missing_attribute_value: 0.5 }); + let intents = trick_intentions::produce(&controller); + assert_eq!(intents.iter().any(|i| i.name == "Dismount"), held); + assert_eq!(intents.iter().any(|i| i.name == "NewDismount"), edge); + } +} diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index 2eafb2d2..f3a3bddf 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -18,8 +18,7 @@ pub(crate) use publication::{ CameraAnimationOutput, CameraAirOutput, CameraOffboardOutput, CameraGrindOutput, CameraEventsOutput, CameraPreferences, }; -pub(crate) use subject::{CameraSubjectFrame, CameraSubjectSnapshot}; -pub(crate) use graph_subject::{CameraGraphEnvironment, CameraGraphSubject}; +pub(crate) use graph_subject::CameraGraphEnvironment; pub(crate) use runtime::CameraRuntime; use bevy::prelude::*; use crate::{app::FrameSet, config::Config}; @@ -30,8 +29,7 @@ struct GameplayCamera; pub(crate) struct CameraPlugin; impl Plugin for CameraPlugin { fn build(&self, app: &mut App) { - app.init_resource::() - .add_systems(Startup, spawn) + app.add_systems(Startup, spawn) .add_systems(Update, present.after(FrameSet::Animation).before(FrameSet::Verification)); } } diff --git a/crates/skate-game/src/camera/graph.rs b/crates/skate-game/src/camera/graph.rs index 55dc7ad9..8d228875 100644 --- a/crates/skate-game/src/camera/graph.rs +++ b/crates/skate-game/src/camera/graph.rs @@ -36,9 +36,17 @@ impl CameraGraph { let node = &source.elements[operation.element]; let a = Attributes::new(&node.attributes); Ok(match operation.name.as_str() { - "CameraChooseShot" => Behavior::Choose { names: shot_names(&a), - incoming: f32::from_bits(a.float_bits("transitionIn", (-1.0_f32).to_bits())), - outgoing: f32::from_bits(a.float_bits("transitionOut", (-1.0_f32).to_bits())) }, + "CameraChooseShot" => { + let names = shot_names(&a); + if names.is_empty() { + return Err("CameraChooseShot has no named shot".into()); + } + Behavior::Choose { + names, + incoming: f32::from_bits(a.float_bits("transitionIn", (-1.0_f32).to_bits())), + outgoing: f32::from_bits(a.float_bits("transitionOut", (-1.0_f32).to_bits())), + } + } "PrintText2D" => Behavior::Print(a.text("text").unwrap_or("").into()), "SlowMotionController" => Behavior::SlowMotion, name => return Err(format!("Unimplemented camera behavior {name}")), @@ -87,12 +95,14 @@ impl Host for CameraHost<'_> { fn begin(&mut self, id: usize, _: [u32; 6], _: &Frame) { match &self.behaviors[id] { Behavior::Choose { names, incoming, outgoing } => { - if let Some(name) = names.first() { - if let Err(error) = self.manager.set_shot(name, false, self.subject, self.database) { - self.error = Some(error); return; - } - self.manager.shots.apply_graph_transition(*incoming, *outgoing); + let Some(name) = names.first() else { + self.error = Some("CameraChooseShot has no named shot".into()); + return; + }; + if let Err(error) = self.manager.set_shot(name, false, self.subject, self.database) { + self.error = Some(error); return; } + self.manager.shots.apply_graph_transition(*incoming, *outgoing); } Behavior::Print(text) => bevy::log::warn!("Stock camera graph: {text}"), Behavior::SlowMotion => { diff --git a/crates/skate-game/src/camera/graph_conditions.rs b/crates/skate-game/src/camera/graph_conditions.rs index b0d1fe10..d96d191a 100644 --- a/crates/skate-game/src/camera/graph_conditions.rs +++ b/crates/skate-game/src/camera/graph_conditions.rs @@ -26,7 +26,7 @@ impl Condition { pub fn parse(a: &Attributes<'_>) -> Result { use Boolean as B; use Value as V; - let name = a.text("name").unwrap_or(""); + let name = a.text("name").ok_or("Camera condition requires name")?; let boolean = match name { "IsInObserverMode" => Some(B::Observer), "IsWipingOut" => Some(B::WipingOut), "IsBrokenBoneSlowMo" => Some(B::BrokenBone), "IsOffboard" => Some(B::Offboard), @@ -62,10 +62,14 @@ impl Condition { return Ok(Self::Numeric(value, crate::graph_host::parse_numeric_condition(a))); } Ok(match name { - "IsCameraTypeActive" => Self::Type(a.text("type").unwrap_or("0").parse() + "IsCameraTypeActive" => Self::Type(a.text("type") + .ok_or("IsCameraTypeActive requires type")? + .parse() .map_err(|_| "Invalid normal camera type")?), "IsPreviousShot" => Self::Previous(shot_names(a).iter().map(|v| key_hash(v)).collect()), - "IsInVolume" => Self::Volume(a.text("volume").unwrap_or("").to_owned()), + "IsInVolume" => Self::Volume(a.text("volume") + .ok_or("IsInVolume requires volume")? + .to_owned()), // Constructor82DF5588 uses case-sensitive comparison and keeps0 // for both Freefall and an unrecognized tweak name. "IsWipeoutBodyTweak" => Self::Tweak(match a.text("tweak").unwrap_or("") { diff --git a/crates/skate-game/src/camera/publication.rs b/crates/skate-game/src/camera/publication.rs index 390cb9e4..40944c2f 100644 --- a/crates/skate-game/src/camera/publication.rs +++ b/crates/skate-game/src/camera/publication.rs @@ -9,6 +9,7 @@ use skate_core::{ }, physics::{board::BodyId, skeleton_animation_record::AnimationPartTransform as Transform}, }; +use bevy::log::debug; /// State record fields absent from the input phase's smaller CurrentStateFields. /// The same output/reset owner must publish these; there is no camera default. @@ -95,6 +96,8 @@ pub(crate) struct CameraPreferences { /// This structure deliberately has no Default: its callers must publish the /// actual native reset/state values even when air/offboard/grinds are inactive. pub(crate) struct CameraPublicationInputs { + /// Completed physics tick represented by these publication inputs. + pub tick: u64, pub state: CameraStateOutput, pub animation: CameraAnimationOutput, pub air: CameraAirOutput, @@ -156,6 +159,8 @@ pub(crate) fn snapshot( skater.animated_skeleton.roots.animation_to_world, processed.flags_2476, ); + //82DF80D8 selects the subject pose separately.82DF70CC/70E8 still + //publish the board transform/position, including while off-board. let record = &skater.skeleton.record; let com = p.reckoning.vector_64.map(f32::from_bits); let up = p.reckoning.vector_96.map(f32::from_bits); @@ -163,6 +168,36 @@ pub(crate) fn snapshot( //The source getter396 is bound to Motion64, despite its camera name //"acceleration". Motion64 is the published deck angular velocity. let acceleration = p.skateboard.vector_64.map(f32::from_bits); + let finite = |name: &str, values: &[f32]| -> Result<(), String> { + if values.iter().all(|value| value.is_finite()) { + Ok(()) + } else { + Err(format!( + "Camera subject owner published non-finite {name}: {values:?}; state={:?}; category={:?}", + processed.state_2508, + processed.category_2512, + )) + } + }; + finite("centre_of_mass", &com)?; + finite("reckoning_up", &up)?; + finite("damped_centre_of_mass", &input.damped_com_80)?; + finite("ground_up", &input.ground_up_80)?; + finite("skeleton_root", skeleton_root.iter().flatten().copied().collect::>().as_slice())?; + for (index, pose) in [1usize, 15, 19, 23].into_iter().map(|index| (index, record.pose[index][3])) { + finite(match index { 1 => "head", 15 => "left_foot", 19 => "right_foot", _ => "hips" }, &pose)?; + } + if velocity.iter().chain(acceleration.iter()).any(|v| !v.is_finite()) { + return Err(format!( + "Camera received non-finite board motion publication: velocity={velocity:?}; acceleration={acceleration:?}; raw_velocity={:?}; raw_acceleration={:?}", + p.skateboard.vector_80, + p.skateboard.vector_64, + )); + } + debug!(state = p.state.state_16, category = p.state.category_12, + board_position = ?physical_transform[3], board_velocity = ?velocity, + board_angular_velocity = ?acceleration, skeleton_root = ?skeleton_root[3], + "camera subject publication"); let last_ground_up = p.ground.vector_96.map(f32::from_bits); let category = p.state.category_12; let state = p.state.state_16; @@ -272,6 +307,7 @@ pub(crate) fn snapshot( flag_684: off.object_held_304, }; Ok(CameraSubjectSnapshot { + tick: input.tick, subject, pose: SubjectPoseInputs { physical_transform, diff --git a/crates/skate-game/src/camera/runtime.rs b/crates/skate-game/src/camera/runtime.rs index 49d39c57..553b103a 100644 --- a/crates/skate-game/src/camera/runtime.rs +++ b/crates/skate-game/src/camera/runtime.rs @@ -22,6 +22,10 @@ pub(crate) struct CameraRuntime { shakes: [ShakeSamples; 2], trajectories: [TrajectoryResult; 3], pub frame: Option, + /// Immutable physical subject publication consumed by the camera graph. + /// Rendering may inspect this snapshot for diagnostics without rebuilding + /// subject fields from mutable skater state. + pub latest_subject: Option, /// Native message order, including End followed by Begin in one graph tick. /// The simulation schedule drains these after advance. pub simulation_rate_requests: Vec, @@ -53,6 +57,7 @@ impl CameraRuntime { shots: StockShots::from_collections(&data)?, settings: settings::manager_settings(&data)?, compass_settings: settings::compass_settings(&data)?, shakes: [samples("1.shk")?, samples("2.shk")?], trajectories: core::array::from_fn(|_| TrajectoryResult::new()), frame: None, + latest_subject: None, simulation_rate_requests: Vec::new() }) } @@ -64,7 +69,16 @@ impl CameraRuntime { pub fn advance(&mut self, dt: f32, snapshot: CameraSubjectSnapshot, world: &BoardWorld, query_gravity: [f32; 4], environment: &CameraGraphEnvironment, moving: &mut impl MovingObstacleProvider) -> Result { - let mut subject = self.subject.publish(snapshot, &self.manager, self.compass_settings); + if let Some(previous) = self.latest_subject.as_ref() + && snapshot.tick <= previous.tick + { + return Err(format!( + "Camera received non-monotonic subject tick: previous={}, current={}", + previous.tick, snapshot.tick, + )); + } + self.latest_subject = Some(snapshot); + let mut subject = self.subject.publish(snapshot, &self.manager, self.compass_settings)?; self.manager.prepare(&subject, self.settings); let requests = self.graph.update(dt, &mut self.manager, &subject, snapshot.graph, environment, &self.shots)?; @@ -83,7 +97,16 @@ impl CameraRuntime { if !frame.position.iter().all(|v| v.is_finite()) || !frame.basis.columns.iter().flatten().all(|v| v.is_finite()) || !frame.field_of_view_degrees.is_finite() { - return Err("Normal gameplay camera produced a non-finite frame".into()); + return Err(format!( + "Normal gameplay camera produced a non-finite frame: frame={frame:?}; lens_length={:?}; aspect_ratio={:?}; subject_transform={:?}; skeleton_root={:?}; ground_normal={:?}; launch_position={:?}; landing_position={:?}", + self.manager.shots.interpolated.lens_length, + self.manager.state.aspect_ratio, + subject.rig.transform, + subject.rig.skeleton_root, + subject.ground_normal, + subject.launch_position, + subject.landing_position, + )); } self.frame = Some(frame); Ok(frame) diff --git a/crates/skate-game/src/camera/subject.rs b/crates/skate-game/src/camera/subject.rs index 9370826b..9bc649b5 100644 --- a/crates/skate-game/src/camera/subject.rs +++ b/crates/skate-game/src/camera/subject.rs @@ -1,6 +1,5 @@ //! Typed physics/animation publication boundary for normal subject82DF69C0. //! The simulation supplies current physical outputs; camera history stays here. -use bevy::prelude::Resource; use skate_core::camera::{ AnchorInputs, Anchors, CameraMan, Compass, CompassPoseInputs, CompassSettings, ManagerSubject, ReferencePointInputs, SubjectPoseInputs, SubjectPosePublisher, @@ -8,6 +7,8 @@ use skate_core::camera::{ #[derive(Clone, Copy, Debug)] pub(crate) struct CameraSubjectSnapshot { + /// Completed physics tick that produced every field in this snapshot. + pub tick: u64, /// Physical flags, trajectory, steering, root and other direct outputs. /// The camera overwrites this value's transform, reference_positions, /// anchors and compass with the source publishers below before use. @@ -21,26 +22,31 @@ pub(crate) struct CameraSubjectSnapshot { pub graph: super::graph_subject::CameraGraphSubject, } -/// Root's simulation publisher replaces this after a complete physical/pose -/// update. None means there is no valid gameplay subject yet. -#[derive(Resource, Default)] -pub(crate) struct CameraSubjectFrame { - pub snapshot: Option, - pub generation: u64, -} - pub(super) struct SubjectPublisher { pose: SubjectPosePublisher, anchors: Anchors, compass: Compass, + last_tick: Option, } impl SubjectPublisher { pub fn new() -> Self { - Self { pose: SubjectPosePublisher::new(), anchors: Anchors::new(), compass: Compass::new() } + Self { + pose: SubjectPosePublisher::new(), + anchors: Anchors::new(), + compass: Compass::new(), + last_tick: None, + } } pub fn publish(&mut self, mut input: CameraSubjectSnapshot, manager: &CameraMan, - settings: CompassSettings) -> ManagerSubject { + settings: CompassSettings) -> Result { + if let Some(last_tick) = self.last_tick && input.tick <= last_tick { + return Err(format!( + "Camera subject publication is not monotonic: previous={}, current={}", + last_tick, input.tick, + )); + } + self.last_tick = Some(input.tick); let pose = self.pose.publish(input.pose); input.subject.rig.transform = pose.transform; input.subject.rig.skeleton_root = input.pose.skeleton_root; @@ -57,6 +63,6 @@ impl SubjectPublisher { input.subject.compass = self.compass.update( if input.subject.reset != 0 { 0.0 } else { f32::from_bits(0x3c88_8889) }, compass_input, settings); - input.subject + Ok(input.subject) } } diff --git a/crates/skate-game/src/graph_host/action.rs b/crates/skate-game/src/graph_host/action.rs index b9376872..ceb40416 100644 --- a/crates/skate-game/src/graph_host/action.rs +++ b/crates/skate-game/src/graph_host/action.rs @@ -12,7 +12,9 @@ use skate_core::graph::{ }; use skate_core::input::body_flip_signal; use skate_core::input::graph_intents::{CreateMgIntent, IntentMutation}; +use skate_core::input::graph_intents::apply_filter; use skate_data::collections::Collections; +use super::outputs::{ActionGraphInput, ActionGraphOutput, GraphCapabilityReport, GraphDiagnostics}; /// Original State output consumed by82BA1680/82BA16F0. #[derive(Clone, Copy, Debug)] @@ -33,7 +35,8 @@ pub struct ActionHost { pub animation_attributes: Vec, pub is_tricking: Option, pub stance: Option<(bool, bool)>, - pub errors: Vec, + tick: u64, + pub errors: GraphDiagnostics, remap: OperationRemap, state_parents: Vec>, next_instance: u32, @@ -55,6 +58,42 @@ impl ActionHost { .instantiate_operations(&graph.source, &mut ActionFactory) .map_err(|error| error.to_string())?; let mut instances = ActionInstances::new(operations.operations); + let mut capabilities = GraphCapabilityReport { + supported_operations: instances + .operations + .iter() + .filter(|instance| instance.unsupported().is_none()) + .count(), + unsupported_operations: instances + .operations + .iter() + .filter_map(|instance| instance.unsupported()) + .map(|operation| format!("{:?} `{}`", operation.kind, operation.name)) + .collect(), + ..GraphCapabilityReport::default() + }; + for &operation in &graph.runtime.operations.conditions { + if let Some(instance) = instances.get(operation) { + if let Some(unsupported) = instance.unsupported() { + capabilities.unsupported_conditions.push(format!( + "{:?} `{}`", + unsupported.kind, unsupported.name + )); + } + } + } + for &operation in &graph.runtime.operations.hooks { + if let Some(instance) = instances.get(operation) { + if let Some(unsupported) = instance.unsupported() { + capabilities.unsupported_hooks.push(format!( + "{:?} `{}`", + unsupported.kind, unsupported.name + )); + } + } + } + // Keep lazy unsupported-operation errors: optional graph branches + // are not a reason to prevent loading the working game. super::condition_nodes::bind_current_states(graph, &mut instances); let mut host = Self::new(instances, graph.runtime.operations.clone()); host.body_flip_settings = Some(body_flip_signal::Settings { @@ -102,7 +141,8 @@ impl ActionHost { state_parents: Vec::new(), remap, stance: None, - errors: Vec::new(), + tick: 0, + errors: GraphDiagnostics::default(), next_instance: 1, created: vec![false; count], const_handlers, @@ -127,6 +167,23 @@ impl ActionHost { } } + pub fn prepare_input(&mut self, input: ActionGraphInput) { + self.errors.clear(); + self.tick = input.tick; + self.action_intents = input.controls.into_values(); + self.motion_intents = input.prior_motion.into_values(); + self.animation_attributes = input.animation_attributes; + } + + pub fn output(&self) -> ActionGraphOutput { + ActionGraphOutput::from_host( + self.tick, + &self.action_intents, + &self.motion_intents, + &self.animation_attributes, + ) + } + fn operation(&self, id: BehaviorId) -> Option<&ActionInstance> { self.remap .behaviors @@ -142,6 +199,34 @@ impl ActionHost { )); } } + + fn publish_board_adjust(&mut self, instance: &ActionInstance) { + let Some(magnitude_name) = instance.config.mg_intent_mag.as_deref() else { + return; + }; + let Some(angle_name) = instance.config.mg_intent_angle.as_deref() else { + return; + }; + + if let Some(value) = self.action_intents.get("BoardAdjustMag").copied() { + self.motion_intents.insert(magnitude_name, value); + } else { + self.motion_intents.remove(magnitude_name); + } + + if let Some(mut value) = self.action_intents.get("BoardAdjustAngle").copied() { + if instance.config.angle_filter != 0 { + value = apply_filter(value, instance.config.angle_filter); + } + if instance.config.negate_on_mirror && self.stance.is_some_and(|(_, mirrored)| mirrored) { + value = -value; + } + self.motion_intents.insert(angle_name, value); + } else { + self.motion_intents.remove(angle_name); + } + } + } impl ConditionHost for ActionHost { @@ -209,7 +294,7 @@ impl Host for ActionHost { if let Some((_, mirrored)) = self.stance { self.trick_handlers[behavior].begin(*group, override_name.as_deref(), &self.action_intents, &mut self.motion_intents, mirrored); } else { - self.errors.push("CreateTrickIntentFromGesture requires published skater stance".into()); + self.errors.push("CreateTrickIntentFromGesture requires published skater stance"); } return; } @@ -226,10 +311,14 @@ impl Host for ActionHost { Some(settings) => self.body_flip[behavior].begin(settings), None => self .errors - .push("BodyFlippingSignal requires stock anim_motion settings".into()), + .push("BodyFlippingSignal requires stock anim_motion settings"), } return; } + if let ActionOperation::BoardAdjust = instance.operation { + self.publish_board_adjust(&instance); + return; + } if let ActionOperation::CreateConstMgIntent = instance.operation { if let Some(name) = instance.config.mg_intent.clone() { let mutation = self.const_handlers[behavior].begin(); @@ -277,12 +366,12 @@ impl Host for ActionHost { if let ActionOperation::BodyFlippingSignal = instance.operation { let Some(settings) = self.body_flip_settings else { self.errors - .push("BodyFlippingSignal requires stock anim_motion settings".into()); + .push("BodyFlippingSignal requires stock anim_motion settings"); return; }; let Some(physical) = &self.condition_inputs.physical_state else { self.errors - .push("BodyFlippingSignal requires published filtered state".into()); + .push("BodyFlippingSignal requires published filtered state"); return; }; // TU3 initializers82F84DA8/82F84DC0 bind these exact keys. @@ -300,6 +389,10 @@ impl Host for ActionHost { } return; } + if let ActionOperation::BoardAdjust = instance.operation { + self.publish_board_adjust(&instance); + return; + } if let ActionOperation::CreateConstMgIntent = instance.operation { if let Some(name) = instance.config.mg_intent.as_deref() { let mutation = self.const_handlers[behavior].update(); diff --git a/crates/skate-game/src/graph_host/action_conditions.rs b/crates/skate-game/src/graph_host/action_conditions.rs index 106ccec2..73d85cb9 100644 --- a/crates/skate-game/src/graph_host/action_conditions.rs +++ b/crates/skate-game/src/graph_host/action_conditions.rs @@ -56,7 +56,7 @@ impl ActionCondition { .ok_or("TimeToLand requires physical condition publication")? .time_to_land, ), - Self::Gesture(group) => group.has_intent(&host.action_intents), + Self::Gesture(group) => group.has_intent(|name| host.action_intents.contains_key(name)), Self::AnimationAttribute { name, sequence_id, diff --git a/crates/skate-game/src/graph_host/action_nodes.rs b/crates/skate-game/src/graph_host/action_nodes.rs index 982ea9df..aca4f94e 100644 --- a/crates/skate-game/src/graph_host/action_nodes.rs +++ b/crates/skate-game/src/graph_host/action_nodes.rs @@ -8,6 +8,8 @@ use skate_data::state_graph::{ pub struct Parameter { pub name: Option, pub mg_intent: Option, + pub mg_intent_mag: Option, + pub mg_intent_angle: Option, pub ag_intent: Option, pub text: Option, pub float_bits: Option, @@ -16,6 +18,8 @@ pub struct Parameter { pub scale: Option, pub on_update: bool, pub filters: [u32; 4], + pub angle_filter: u32, + pub negate_on_mirror: bool, } impl Parameter { @@ -23,6 +27,8 @@ impl Parameter { Self { name: attributes.text("name").map(str::to_owned), mg_intent: attributes.text("MGIntent").map(str::to_owned), + mg_intent_mag: attributes.text("MGIntentMag").map(str::to_owned), + mg_intent_angle: attributes.text("MGIntentAngle").map(str::to_owned), ag_intent: attributes.text("AGIntent").map(str::to_owned), text: attributes.text("value").map(str::to_owned), float_bits: attributes.get("value").map(|value| value.float_bits), @@ -45,6 +51,8 @@ impl Parameter { filter(attributes.text("fakieFilter")), filter(attributes.text("mirrorFilter")), ], + angle_filter: filter(attributes.text("angleFilter")), + negate_on_mirror: attributes.boolean_byte("negateOnMirror", 0) != 0, } } } @@ -198,10 +206,6 @@ impl ActionInstance { kind: *kind, name: name.clone(), }), - ActionOperation::BoardAdjust => Some(UnsupportedOperation { - kind: OperationKind::Behavior, - name: "BoardAdjust".into(), - }), _ => None, } } diff --git a/crates/skate-game/src/graph_host/mod.rs b/crates/skate-game/src/graph_host/mod.rs index 158d8cbf..7d3531e0 100644 --- a/crates/skate-game/src/graph_host/mod.rs +++ b/crates/skate-game/src/graph_host/mod.rs @@ -45,3 +45,6 @@ mod pumping_settings; pub mod pushing; pub mod pushing_settings; mod turning_settings; + +pub(crate) mod outputs; +pub(crate) mod motion_stock_gameplay; diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs index 7eaf739d..f908e730 100644 --- a/crates/skate-game/src/graph_host/motion.rs +++ b/crates/skate-game/src/graph_host/motion.rs @@ -1,3 +1,5 @@ +#[path = "motion_stock_execute.rs"] +mod stock_execution; #[path = "motion_air_execute.rs"] mod air_execution; #[path = "motion_execute.rs"] @@ -130,7 +132,13 @@ impl Instance { } } } +use super::outputs::{ActionControls, GraphDiagnostics, GraphEffects, MotionGraphInput, TurningOutput}; + pub struct MotionHost { + pub action_controls: ActionControls, + pub turning_output: TurningOutput, + pub graph_effects: GraphEffects, + pub moving_objects: super::motion_stock_gameplay::MovingObjectRegistry, pub grind_physical: super::motion_grind::Physical, grind_settings: super::motion_grind::Settings, pub offboard_output: skate_core::player::input_phase::OffBoardOutputFields, @@ -183,7 +191,7 @@ pub struct MotionHost { bump_settings: skate_core::animation::bump::Settings, pub allow_pumping: bool, pub riding: super::motion_riding::RidingState, - pub errors: Vec, + pub errors: GraphDiagnostics, pub(super) state_parents: Vec>, operations: Vec, instances: Vec, @@ -200,6 +208,17 @@ pub struct MotionHost { next_instance: u32, } impl MotionHost { + pub(super) fn end_gesture_channels(&mut self) { + // EndGesture nodes share the graph-wide gesture channel owner. The + // active CharacterGesture instance is found by its operation binding. + for instance in &mut self.instances { + if let Instance::CharacterGesture(state) = instance { + state.end(&mut self.animation); + } + } + self.gesture_publication = None; + } + pub fn from_graph( graph: &LoadedGraph, data: &Collections, @@ -269,6 +288,10 @@ impl MotionHost { data.boolean("anim_motion", "jumping", "clamp_gesture_to_antic")?, ), action_intents: IntentMap::new(), + action_controls: ActionControls::default(), + turning_output: TurningOutput::default(), + graph_effects: GraphEffects::default(), + moving_objects: Default::default(), time_tags: None, physical: None, push_state, @@ -293,7 +316,7 @@ impl MotionHost { //8258F488 seeds bit24, and reset825953B0 preserves that bit. allow_pumping: true, riding: super::motion_riding::RidingState::new(), - errors: Vec::new(), + errors: GraphDiagnostics::default(), state_parents: graph.binding.states.iter().map(|s| s.parent).collect(), operations, instances, @@ -310,6 +333,18 @@ impl MotionHost { next_instance: 1, }) } + pub fn accept_action_graph(&mut self, input: MotionGraphInput) { + let MotionGraphInput { tick, action } = input; + debug_assert_eq!(tick, action.tick); + self.errors.clear(); + self.turning_output = action.turning; + self.graph_effects = action.effects.clone(); + self.action_controls = action.controls.clone(); + // Clone before handing the full output to MotionAnimation so the two + // consumers observe one immutable packet. + self.action_intents = action.controls.authored_values(); + self.animation.accept_action_graph(action); + } fn run(&mut self, id: BehaviorId, frame: &Frame, phase: u8) { if let Err(error) = self.execute(id, frame, phase) { self.errors diff --git a/crates/skate-game/src/graph_host/motion_animation.rs b/crates/skate-game/src/graph_host/motion_animation.rs index b9b17486..56c3c908 100644 --- a/crates/skate-game/src/graph_host/motion_animation.rs +++ b/crates/skate-game/src/graph_host/motion_animation.rs @@ -17,7 +17,10 @@ use skate_core::graph::intents::IntentMap; use skate_data::animation_metadata::{AnimationMetadata, TreeMetadata}; use tree_builder::build; +use super::outputs::ActionGraphOutput; + pub struct MotionAnimation { + pub grab_type: Option, metadata: AnimationMetadata, current: Option, pub channels: super::motion_channels::MotionChannels, @@ -37,6 +40,10 @@ pub struct MotionAnimation { property: AdvanceResult, } impl MotionAnimation { + pub fn accept_action_graph(&mut self, output: ActionGraphOutput) { + output.motion_effects.apply_to(&mut self.motion_intents); + } + /// TU3 JumpInto Update82BACDC0: query the live tree with mask31, /// then seek to the attribute's begin time (not its scalar payload). pub fn jump_into(&mut self, name: AttributeName) -> Result<(), String> { @@ -58,6 +65,7 @@ impl MotionAnimation { pub fn reset_from_stock(&mut self) { self.current = None; self.current_name = None; + self.grab_type = None; self.channels.reset_from_stock(); self.motion_intents.clear(); self.filtered_intents.clear(); @@ -72,6 +80,7 @@ impl MotionAnimation { current: None, channels: super::motion_channels::MotionChannels::default(), current_name: None, + grab_type: None, motion_intents: IntentMap::new(), filtered_intents: IntentMap::new(), motion_attributes: Vec::new(), @@ -230,6 +239,13 @@ impl MotionAnimation { self.motion_attributes .push(MotionGraphAttribute { name, value }); } + pub fn set_grab_type(&mut self, grab_type: super::motion_stock_gameplay::GrabType) { + self.grab_type = Some(grab_type); + //82B971F8 stores ISkaterAnim360; this is not a physics attribute. + } + pub fn clear_grab_type(&mut self) { + self.grab_type = None; + } pub fn build_tree(&self, name: &str) -> Result { build( &self.metadata, diff --git a/crates/skate-game/src/graph_host/motion_channels.rs b/crates/skate-game/src/graph_host/motion_channels.rs index 4fe2e916..c2fce7bb 100644 --- a/crates/skate-game/src/graph_host/motion_channels.rs +++ b/crates/skate-game/src/graph_host/motion_channels.rs @@ -53,6 +53,17 @@ impl MotionChannels { if value >= 0.0 { value } else { 0.0 } }) } + ///82D1D4B8: clamp the actual child time to [0, child length]. + pub fn elapsed(&self, name: &str) -> f32 { + self.channels.iter() + .find(|c| intent_key(&c.name) == intent_key(name)) + .map_or(0.0, |c| { + let time = c.tree.time(); + let lower = if -time >= 0.0 { 0.0 } else { time }; + let length = c.tree.length(); + if length - lower >= 0.0 { lower } else { length } + }) + } ///82B96FF0 reads Channel136, set/cleared by Start/CompleteTransition. pub fn in_transition(&self, name: &str) -> bool { self.channels diff --git a/crates/skate-game/src/graph_host/motion_character_gesture.rs b/crates/skate-game/src/graph_host/motion_character_gesture.rs index 25c2bcd3..8a4e246c 100644 --- a/crates/skate-game/src/graph_host/motion_character_gesture.rs +++ b/crates/skate-game/src/graph_host/motion_character_gesture.rs @@ -62,6 +62,15 @@ impl CharacterGesture { } } + /// EndGesture82BAA320's explicit teardown path. The native operation ends + /// all gesture-owned channels in reverse owner order and clears the + /// persistent selection state. + pub fn end(&mut self, animation: &mut MotionAnimation) { + end_all(animation); + self.clear_active(); + self.stage = -1; + } + pub fn update( &mut self, animation: &mut MotionAnimation, diff --git a/crates/skate-game/src/graph_host/motion_conditions.rs b/crates/skate-game/src/graph_host/motion_conditions.rs index 8f3e32c6..c897c90d 100644 --- a/crates/skate-game/src/graph_host/motion_conditions.rs +++ b/crates/skate-game/src/graph_host/motion_conditions.rs @@ -74,6 +74,10 @@ pub enum MotionCondition { Riding(super::motion_riding_conditions::MotionRidingCondition), /// Native Air condition: PhysOutAnimation collision/trajectory time. TimeToLand(NumericCondition), + CurrentGrabType(super::motion_stock_gameplay::GrabType), + CrouchedForGrabCycle, + TrucksOrDeckInContact, + HasTweak(NumericCondition), ///DistToEdge82BA8238: completed OffBoard116, not a fresh nearest-rail query. DistToEdge(NumericCondition), /// Native off-board trajectory time (PhysOutOffBoard+32). @@ -183,6 +187,10 @@ impl MotionCondition { } "DistToEdge" => Self::DistToEdge(numeric()), "IsDroppingIn" => Self::DroppingIn, + "TrucksOrDeckInContact" => Self::TrucksOrDeckInContact, + "HasTweak" => Self::HasTweak(numeric()), + "CurrentGrabType" => Self::CurrentGrabType(super::motion_stock_gameplay::GrabType::parse(a.text("grab").unwrap_or(""))?), + "IsCrouchedEnoughForBlendToGrabCycle" => Self::CrouchedForGrabCycle, "TimeToLand" => Self::TimeToLand(numeric()), "OBTimeToLand" => Self::ObTimeToLand(numeric()), "OBTrajTime" => Self::ObTrajTime(numeric()), @@ -192,6 +200,25 @@ impl MotionCondition { pub fn evaluate(&self, host: &MotionHost, frame: &Frame) -> Result { use skate_core::animation::playback_parameters::ParameterInputs; Ok(match self { + //82BA42E0 reads Collision3472 (trucks) OR3475 (deck), not wheels. + Self::TrucksOrDeckInContact => host.gameplay_conditions + .ok_or("Contact condition requires physical publication")?.trucks_or_deck_contact, + //82BA6B90 shares the filtered-map accessor with82BA6AC0. At least + //one component must exist even when no numeric comparison is authored. + Self::HasTweak(n) => { + let x = host.animation.filtered_intent("TweakX"); + let y = host.animation.filtered_intent("TweakY"); + (x.is_some() || y.is_some()) && { + let (x, y) = (x.unwrap_or(0.0), y.unwrap_or(0.0)); + n.matches(skate_core::input::controller::magnitude(y.mul_add(y, x * x))) + } + }, + //82BA7848 -> ISkaterAnim+100 ->82B97200 compares the enum at360. + Self::CurrentGrabType(grab) => host.animation.grab_type == Some(*grab), + //82BBDE40: PhysOutSkeleton72 < literal821EE79C (strictly 0.6). + Self::CrouchedForGrabCycle => host.crouching_physical + .ok_or("Grind grab requires the published skeleton height")? + .animation_height_72 < f32::from_bits(0x3f19_999a), Self::CanBipedLand => host.offboard_output.landing_normal_192[1] > 0.85, Self::BipedCommitted => host.offboard_output.flag_329 != 0, Self::EnoughDistToObstacle { @@ -236,7 +263,7 @@ impl MotionCondition { Self::BipedGroundThin => host.offboard_ground_thin, Self::LocoState(condition) => condition.evaluate(host.offboard_locomotion), Self::ShouldLeaveSlide { right } => host.slide_latch.should_leave(*right), - Self::Gesture(group) => group.has_intent(&host.action_intents), + Self::Gesture(group) => group.has_intent(|name| host.action_intents.contains_key(name)), Self::Shared(condition) => condition .evaluate( &host.condition_inputs, @@ -251,12 +278,11 @@ impl MotionCondition { Self::Riding(condition) => condition.evaluate(host)?, Self::DistToEdge(n) => n.matches(host.offboard_output.distance_116), Self::DroppingIn => host.grind_physical.dropping_in, - Self::TimeToLand(n) => n.matches( - host.gameplay_conditions - .as_ref() - .ok_or("TimeToLand requires physical condition publication")? - .time_to_land, - ), + Self::TimeToLand(n) => { + let p = host.gameplay_conditions + .ok_or("TimeToLand requires physical condition publication")?; + p.time_to_land_valid && n.matches(p.time_to_land) + }, Self::ObTimeToLand(n) | Self::ObTrajTime(n) => n.matches( host.gameplay_conditions .as_ref() diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs index 1ae1a2e1..7baa302a 100644 --- a/crates/skate-game/src/graph_host/motion_execute.rs +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -15,6 +15,13 @@ impl MotionHost { .get(behavior) .ok_or("Unbound MotionGraph behavior")?; let operation = self.operations[id].clone(); + if let MotionOperation::StockGameplay(operation) = operation { + return super::stock_execution::execute(self, operation, phase); + } + if let MotionOperation::EndGesture = operation { + if phase == 0 { self.end_gesture_channels(); } + return Ok(()); + } if let MotionOperation::Grind(operation) = operation { let Instance::Grind(state)=&mut self.instances[behavior] else {return Err("Grind instance mismatch".into())}; let height=self.crouching_physical.map(|p|p.animation_height_72).ok_or("Grind requires physical height")?; diff --git a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs index 9390c375..410162e1 100644 --- a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs +++ b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs @@ -21,8 +21,10 @@ pub struct GameplayConditions { pub handplant_flags: u32, pub time_to_skitch: f32, pub skitch_transition_time: f32, - /// PhysOutAnimation+144 collision time used by TimeToLand. + /// TimeToLand82BA7250 reads Air184 when Air437 is valid. pub time_to_land: f32, + pub time_to_land_valid: bool, + pub trucks_or_deck_contact: bool, /// PhysOutOffBoard+32, shared by OBTimeToLand and OBTrajTime. pub offboard_time_to_land: f32, pub tricks_blocked_on_stairs: bool, diff --git a/crates/skate-game/src/graph_host/motion_native.rs b/crates/skate-game/src/graph_host/motion_native.rs index 1580d70c..0040cdb0 100644 --- a/crates/skate-game/src/graph_host/motion_native.rs +++ b/crates/skate-game/src/graph_host/motion_native.rs @@ -79,6 +79,7 @@ impl Operation { /// this game.82595D08 sets a name and ORs a bit in the graph's score packet. #[derive(Default)] pub struct ScorePacket { + pub grab: Option<(skate_core::animation::output::attributes::AttributeName, [f32; 2])>, pub trick: Option, pub name: Option, pub flags: u32, diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs index 655cb187..614c72a0 100644 --- a/crates/skate-game/src/graph_host/motion_nodes.rs +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -51,6 +51,8 @@ pub enum MotionOperation { HandService(super::motion_hand_services::Operation), IntentFilter(super::motion_intent_filter::Operation), CharacterGesture, + EndGesture, + StockGameplay(super::motion_stock_gameplay::Operation), Shove(super::motion_shove::ShoveOperation), ResetAnimation(super::motion_reset::Operation), UpdateRidingFakie(skate_core::animation::riding_fakie::Settings), @@ -155,6 +157,7 @@ impl OperationFactory for MotionFactory { super::motion_tricks::Operation::parse(a).map(MotionOperation::Trick) } "CharacterGesture" => Some(MotionOperation::CharacterGesture), + "EndGesture" => Some(MotionOperation::EndGesture), "DisallowPumping" => Some(MotionOperation::DisallowPumping), "FakieHeadChannel" => Some(MotionOperation::FakieHeadChannel), "SetSpeed" => Some(MotionOperation::SetSpeed { @@ -178,6 +181,9 @@ impl OperationFactory for MotionFactory { }, )), // Factory82BC6D40 defaults both identifiers to the string0. + name if super::motion_stock_gameplay::Operation::recognizes(name) => Some( + MotionOperation::StockGameplay(super::motion_stock_gameplay::Operation::parse(a)), + ), "AttachIntent" => Some(MotionOperation::AttachIntent { intent: a.text("intent").unwrap_or("0").into(), attribute: encode(a.text("attr").unwrap_or("0").as_bytes()), diff --git a/crates/skate-game/src/graph_host/motion_stock_execute.rs b/crates/skate-game/src/graph_host/motion_stock_execute.rs new file mode 100644 index 00000000..96fc4775 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_stock_execute.rs @@ -0,0 +1,88 @@ +//! Imported grab/tweak and moving-object registry behavior dispatch. +use super::*; +pub(super) fn execute(host: &mut MotionHost, operation: super::super::motion_stock_gameplay::Operation, phase: u8) -> Result<(), String> { + match operation { + crate::graph_host::motion_stock_gameplay::Operation::InitMovingObjects { path } => { + if phase == 0 { + host.moving_objects.register(path); + } + } + crate::graph_host::motion_stock_gameplay::Operation::MovingObject { path } => { + match phase { + 0 => host.moving_objects.begin(&path)?, + 1 => { + let active = host.moving_objects.update(&path)?; + if !active { + return Err(format!("MovingObject is not active: {path}")); + } + } + _ => host.moving_objects.end(), + } + } + crate::graph_host::motion_stock_gameplay::Operation::SetGrabType { grab } => { + let grab = crate::graph_host::motion_stock_gameplay::GrabType::parse(&grab)?; + if phase == 0 { + host.animation.set_grab_type(grab); + } else if phase == 2 { + host.animation.clear_grab_type(); + } + } + crate::graph_host::motion_stock_gameplay::Operation::ScoringGrabs { + grab_name, up, left, down, right, intent_x, intent_y, invert_y, polar, + } => { + if phase == 1 { + // TU3 82BBEF60 publishes a score name and vector. + // It never starts animations or changes channel influence. + let first = host.animation.filtered_intent(&intent_x).unwrap_or(0.0); + let second = host.animation.filtered_intent(&intent_y).unwrap_or(0.0); + let (x, y) = if polar { + let x = second * first.sin(); + let y = second * first.cos(); + (x, if invert_y { -y } else { y }) + } else { + (first, second) + }; + let selected = crate::graph_host::motion_stock_gameplay::select_grab_score( + &grab_name, [up.as_deref(), left.as_deref(), down.as_deref(), right.as_deref()], x, y, + ); + host.score_packet.grab = Some((encode(selected.as_bytes()), [x, y])); + } + } + crate::graph_host::motion_stock_gameplay::Operation::TweakProject { + intent_x, intent_y, attribute_x, attribute_y, + } => { + if phase == 1 { + // TU3 82BABFD0/82BABFFC read the same slot+8 map + // written by FilterMotionGraphIntent at 82BB19D0. + // This is filtered output, NOT raw AG-produced MG + // input. Bypassing it removes authored tweak timing. + let source_x = host.animation.filtered_intent(&intent_x); + let source_y = host.animation.filtered_intent(&intent_y); + if source_x.is_none() && source_y.is_none() { + return Ok(()); + } + let mut x = source_x.unwrap_or(0.0); + let mut y = source_y.unwrap_or(0.0); + let total = x.abs() + y.abs(); + if total > 1.0 { + let scale = 1.0 / total; + x *= scale; + y *= scale; + } + host.animation.set_attribute(SettableAttribute { + name: attribute_x, + value: x, + normalized: false, + sequence_id: -1, + }); + host.animation.set_attribute(SettableAttribute { + name: attribute_y, + value: y, + normalized: false, + sequence_id: -1, + }); + } + } + } + Ok(()) +} diff --git a/crates/skate-game/src/graph_host/motion_stock_gameplay.rs b/crates/skate-game/src/graph_host/motion_stock_gameplay.rs new file mode 100644 index 00000000..074ea5cb --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_stock_gameplay.rs @@ -0,0 +1,191 @@ +//! Imported grab, tweak scoring and moving-object registration operations. +//! Existing native grind, manual, landing and offboard owners take precedence. +use skate_data::state_graph::attributes::Attributes; +use skate_core::animation::output::attributes::AttributeName; +use skate_core::animation::skeleton_input::name::encode; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GrabType { + Fs, + Bs, + Nose, + Tail, +} + +impl GrabType { + pub fn parse(value: &str) -> Result { + match value { + "FS" => Ok(Self::Fs), + "BS" => Ok(Self::Bs), + "Nose" => Ok(Self::Nose), + "Tail" => Ok(Self::Tail), + other => Err(format!("SetGrabType has unknown grab type {other:?}")), + } + } + pub fn as_value(self) -> f32 { + // TU3 82BB9BD0: zero is no grab; authored types are one through four. + match self { Self::Fs => 1.0, Self::Bs => 2.0, Self::Nose => 3.0, Self::Tail => 4.0 } + } +} + +#[derive(Clone, Debug, Default)] +pub struct MovingObjectRegistry { + objects: BTreeMap, + active: Option, +} + +impl MovingObjectRegistry { + /// InitMovingObjects::Begin normalizes the authored resource path before + /// hashing it with the stock DJB2 accumulator (5381, multiply by 33). + pub fn register(&mut self, path: impl Into) -> u32 { + let normalized = normalize_path(&path.into()); + let hash = djb2(&normalized); + self.objects.insert(hash, normalized); + hash + } + + pub fn begin(&mut self, path: &str) -> Result<(), String> { + let hash = djb2(&normalize_path(path)); + if !self.objects.contains_key(&hash) { + return Err(format!("MovingObject resource is not registered: {path}")); + } + self.active = Some(hash); + Ok(()) + } + + pub fn update(&mut self, path: &str) -> Result { + let hash = djb2(&normalize_path(path)); + if !self.objects.contains_key(&hash) { + return Err(format!("MovingObject update references unknown resource: {path}")); + } + Ok(self.active == Some(hash)) + } + + pub fn end(&mut self) { + self.active = None; + } + + pub fn active(&self) -> bool { + self.active.is_some() + } +} + +fn normalize_path(path: &str) -> String { + path.replace('/', "\\") +} + +/// TU3 82BBF180..F2EC, constants at 820DDEA8..DEE4. Sector windows +/// expand when neighboring variants are absent; first matching window wins. +pub(super) fn select_grab_score<'a>( + base: &'a str, + directions: [Option<&'a str>; 4], + x: f32, + y: f32, +) -> &'a str { + let [up, left, down, right] = directions; + if (x * x + y * y).sqrt() <= 0.5 { + return base; + } + let angle = y.atan2(x).rem_euclid(std::f32::consts::TAU); + let windows = [ + (up, if right.is_some() { 44.0 } else { 359.0 }, if left.is_some() { 134.0 } else { 181.0 }), + (left, if up.is_some() { 44.0 } else { 89.0 }, if down.is_some() { 226.0 } else { 271.0 }), + (down, if left.is_some() { 224.0 } else { 179.0 }, if right.is_some() { 316.0 } else { 1.0 }), + (right, if down.is_some() { 314.0 } else { 269.0 }, if up.is_some() { 46.0 } else { 91.0 }), + ]; + for (name, start, end) in windows { + let start = start * (std::f32::consts::PI / 180.0); + let end = end * (std::f32::consts::PI / 180.0); + let inside = if end < start { angle >= start || angle <= end } + else { angle >= start && angle <= end }; + if inside && let Some(name) = name { return name; } + } + base +} + +fn djb2(value: &str) -> u32 { + value.bytes().fold(5381u32, |hash, byte| { + hash.wrapping_mul(33).wrapping_add(u32::from(byte)) + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Operation { + InitMovingObjects { path: String }, + MovingObject { path: String }, + SetGrabType { grab: String }, + ScoringGrabs { + grab_name: String, + up: Option, left: Option, down: Option, right: Option, + intent_x: String, intent_y: String, + invert_y: bool, polar: bool, + }, + TweakProject { + intent_x: String, intent_y: String, + attribute_x: AttributeName, attribute_y: AttributeName, + }, +} +impl Operation { + pub fn recognizes(name: &str) -> bool { + matches!(name, "InitMovingObjects" | "MovingObject" | "SetGrabType" | "ScoringGrabs" | "TweakProject") + } + pub fn parse(a: &Attributes<'_>) -> Self { + match a.text("name").unwrap_or("") { + "InitMovingObjects" => Self::InitMovingObjects { path: authored_path(a) }, + "MovingObject" => Self::MovingObject { path: authored_path(a) }, + "SetGrabType" => Self::SetGrabType { grab: a.text("grab").unwrap_or("").to_owned() }, + "ScoringGrabs" => Self::ScoringGrabs { + grab_name: required_text(a, "grabName"), + up: a.text("up").map(str::to_owned), + left: a.text("left").map(str::to_owned), + down: a.text("down").map(str::to_owned), + right: a.text("right").map(str::to_owned), + intent_x: a.text("intentX").or_else(|| a.text("angle")).unwrap_or("").to_owned(), + intent_y: a.text("intentY").or_else(|| a.text("magnitude")).unwrap_or("").to_owned(), + invert_y: a.boolean_byte("invertY", 0) != 0, + polar: a.text("intentX").is_none(), + }, + "TweakProject" => Self::TweakProject { + intent_x: "TweakX".to_owned(), intent_y: "TweakY".to_owned(), + attribute_x: encode(b"tweak_x"), attribute_y: encode(b"tweak_y"), + }, + _ => unreachable!("unrecognized stock gameplay operation"), + } + } +} + +fn authored_path(a: &Attributes<'_>) -> String { + a.text("path") + .or_else(|| a.text("object")) + .or_else(|| a.text("nameToFind")) + .unwrap_or("") + .to_owned() +} + +fn required_text(a: &Attributes<'_>, name: &str) -> String { + a.text(name).unwrap_or("").to_owned() +} + +#[cfg(test)] +mod tests { + use super::MovingObjectRegistry; + + #[test] + fn moving_object_paths_use_stock_separator_normalization() { + let mut registry = MovingObjectRegistry::default(); + registry.register("ram:/world/objects/car"); + registry.begin("ram:\\world\\objects\\car").unwrap(); + assert!(registry.active()); + assert!(registry.update("ram:/world/objects/car").unwrap()); + registry.end(); + assert!(!registry.active()); + } + + #[test] + fn unknown_moving_object_is_an_explicit_producer_error() { + let mut registry = MovingObjectRegistry::default(); + let error = registry.begin("ram:/missing").unwrap_err(); + assert!(error.contains("not registered")); + } +} diff --git a/crates/skate-game/src/graph_host/outputs.rs b/crates/skate-game/src/graph_host/outputs.rs new file mode 100644 index 00000000..0e36a8f8 --- /dev/null +++ b/crates/skate-game/src/graph_host/outputs.rs @@ -0,0 +1,305 @@ +//! Explicit graph-phase transport. +//! +//! These records are the typed boundary between the two native graph phases. +//! Graph hosts publish complete records. Unsupported branch execution keeps +//! the existing explicit runtime error policy. + +use skate_core::animation::output::attributes::AnimationAttribute; +use skate_core::graph::intents::IntentMap; +use skate_core::player::state::PhysicalStateId; + +#[derive(Clone, Debug, Default, PartialEq)] +struct AuthoredSignals { + values: IntentMap, +} + +impl AuthoredSignals { + fn from_values(values: &IntentMap) -> Self { + Self { values: values.clone() } + } + + fn into_values(self) -> IntentMap { + self.values + } + + fn get(&self, name: &str) -> Option { + self.values.get(name).copied() + } + + fn contains(&self, name: &str) -> bool { + self.values.contains_key(name) + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WipeoutControls { + pub request: bool, + pub air_body_tweak: [f32; 2], + pub gesture: Option<[f32; 2]>, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BoardControls { + pub drop_requested: bool, + pub throw_requested: bool, + pub retrieve_requested: bool, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ActionControls { + pub wipeout: WipeoutControls, + pub board: BoardControls, + authored_values: AuthoredSignals, +} + +impl ActionControls { + pub fn has(&self, name: &str) -> bool { + self.authored_values.contains(name) + } + + pub fn value(&self, name: &str) -> Option { + self.authored_values.get(name) + } + + pub(crate) fn authored_values(&self) -> IntentMap { + self.authored_values.clone().into_values() + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MotionEffects { + values: AuthoredSignals, +} + +impl MotionEffects { + pub fn from_values(values: &IntentMap) -> Self { + Self { values: AuthoredSignals::from_values(values) } + } + + pub fn apply_to(&self, destination: &mut IntentMap) { + *destination = self.values.clone().into_values(); + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ActionInput { + values: AuthoredSignals, +} + +impl ActionInput { + pub fn from_values(values: &IntentMap) -> Self { + Self { values: AuthoredSignals::from_values(values) } + } + + pub(crate) fn into_values(self) -> IntentMap { + self.values.into_values() + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PriorMotionState { + values: AuthoredSignals, +} + +impl PriorMotionState { + pub fn from_values(values: &IntentMap) -> Self { + Self { values: AuthoredSignals::from_values(values) } + } + + pub(crate) fn into_values(self) -> IntentMap { + self.values.into_values() + } +} + +/// Tick-scoped graph diagnostics. Native graph failures are actionable, but a +/// long-lived host must not grow an error vector without bound when an authored +/// graph repeatedly reaches an unsupported node. +#[derive(Debug, Default)] +pub struct GraphDiagnostics { + messages: Vec, + overflowed: bool, +} + +impl GraphDiagnostics { + const CAPACITY: usize = 64; + + pub fn push(&mut self, message: impl Into) { + if self.messages.len() < Self::CAPACITY { + self.messages.push(message.into()); + } else { + self.overflowed = true; + } + } + + pub fn is_empty(&self) -> bool { + self.messages.is_empty() && !self.overflowed + } + + pub fn clear(&mut self) { + self.messages.clear(); + self.overflowed = false; + } + + pub fn join(&self, separator: &str) -> String { + let mut message = self.messages.join(separator); + if self.overflowed { + if !message.is_empty() { + message.push_str(separator); + } + message.push_str("graph diagnostics truncated after 64 entries"); + } + message + } +} + +/// Load-time declaration of what a stock graph can execute in this build. +/// This is a diagnostic for a failed load, not a runtime compatibility mode. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GraphCapabilityReport { + pub supported_operations: usize, + pub unsupported_operations: Vec, + pub unsupported_conditions: Vec, + pub unsupported_hooks: Vec, +} + +impl GraphCapabilityReport { + pub fn require_complete(&self, graph: &str) -> Result<(), String> { + if self.unsupported_operations.is_empty() + && self.unsupported_conditions.is_empty() + && self.unsupported_hooks.is_empty() + { + return Ok(()); + } + Err(format!( + "{graph} contains unsupported authored features: operations={:?}, conditions={:?}, hooks={:?}", + self.unsupported_operations, + self.unsupported_conditions, + self.unsupported_hooks, + )) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TurningOutput { + pub turn: f32, + pub raw_turn: f32, + pub hard_turn: f32, +} + +/// Typed graph effects with declared destinations. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GraphEffects { + pub state_requests: Vec, +} + +#[derive(Clone, Debug)] +pub struct ActionGraphInput { + pub tick: u64, + pub controls: ActionInput, + pub prior_motion: PriorMotionState, + pub animation_attributes: Vec, +} + +#[derive(Clone, Debug)] +pub struct ActionGraphOutput { + pub tick: u64, + pub controls: ActionControls, + pub motion_effects: MotionEffects, + pub turning: TurningOutput, + pub animation_attributes: Vec, + pub effects: GraphEffects, +} + +impl ActionGraphOutput { + pub fn from_host( + tick: u64, + action_intents: &IntentMap, + motion_intents: &IntentMap, + animation_attributes: &[AnimationAttribute], + ) -> Self { + Self { + tick, + controls: ActionControls { + wipeout: WipeoutControls { + request: action_intents.contains_key("WipeOutRequest"), + air_body_tweak: [ + action_intents.get("OB_AirBodyTweakX").copied().unwrap_or(0.0), + action_intents.get("OB_AirBodyTweakY").copied().unwrap_or(0.0), + ], + gesture: match ( + action_intents.get("WipeoutGestureX"), + action_intents.get("WipeoutGestureY"), + ) { + (Some(x), Some(y)) => Some([*x, *y]), + _ => None, + }, + }, + board: BoardControls { + drop_requested: action_intents.contains_key("OB_DropBoard"), + throw_requested: action_intents.contains_key("OB_ThrowBoard"), + retrieve_requested: action_intents.contains_key("OB_RetrieveBoard"), + }, + authored_values: AuthoredSignals::from_values(action_intents), + }, + motion_effects: MotionEffects::from_values(motion_intents), + turning: TurningOutput { + turn: motion_intents.get("Turn").copied().unwrap_or(0.0), + raw_turn: motion_intents.get("RawTurn").copied().unwrap_or(0.0), + hard_turn: motion_intents.get("HardTurn").copied().unwrap_or(0.0), + }, + animation_attributes: animation_attributes.to_vec(), + effects: GraphEffects::default(), + } + } +} + +#[derive(Clone, Debug)] +pub struct MotionGraphInput { + pub tick: u64, + pub action: ActionGraphOutput, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_captures_named_turning_without_mutable_map_handoff() { + let mut intents = IntentMap::new(); + intents.insert("Turn", 0.25); + intents.insert("RawTurn", -0.5); + intents.insert("HardTurn", 1.0); + let output = ActionGraphOutput::from_host(7, &IntentMap::new(), &intents, &[]); + assert_eq!(output.tick, 7); + assert_eq!(output.turning.turn, 0.25); + assert_eq!(output.turning.raw_turn, -0.5); + assert_eq!(output.turning.hard_turn, 1.0); + assert!(output.controls.authored_values().is_empty()); + } + + #[test] + fn output_projects_action_family_controls() { + let mut action = IntentMap::new(); + action.insert("WipeOutRequest", 1.0); + action.insert("OB_AirBodyTweakX", 0.25); + action.insert("OB_AirBodyTweakY", -0.5); + action.insert("WipeoutGestureX", 0.75); + action.insert("WipeoutGestureY", -0.25); + let output = ActionGraphOutput::from_host(3, &action, &IntentMap::new(), &[]); + assert!(output.controls.wipeout.request); + assert_eq!(output.controls.wipeout.air_body_tweak, [0.25, -0.5]); + assert_eq!(output.controls.wipeout.gesture, Some([0.75, -0.25])); + } + + #[test] + fn graph_diagnostics_are_bounded_and_tick_resettable() { + let mut diagnostics = GraphDiagnostics::default(); + for index in 0..65 { + diagnostics.push(format!("diagnostic {index}")); + } + assert!(!diagnostics.is_empty()); + assert!(diagnostics.join("\n").contains("truncated")); + diagnostics.clear(); + assert!(diagnostics.is_empty()); + } +} diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 906e0a90..4ab9ad4d 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -9,11 +9,26 @@ pub(crate) mod gesture_mapping; pub(crate) mod gesture_input; mod platform; pub(crate) use controllers::{ControllerInput, ControllerStatus}; +use skate_core::input::tick::TickInput; + +#[derive(Resource, Clone, Copy, Debug)] +pub(crate) struct PublishedTickInput(pub TickInput); + +impl Default for PublishedTickInput { + fn default() -> Self { + Self(TickInput::new( + 0, + skate_core::input::gameplay_map::GameplayActions::from_values([0.0; 18]), + false, + )) + } +} pub(crate) struct InputPlugin; impl Plugin for InputPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .add_systems(PreUpdate, poll_controllers.run_if(crate::graphics_menu::gameplay_active)) .add_systems(FixedUpdate, publish_actions.in_set(SimulationSet::Input)); } @@ -35,6 +50,10 @@ pub(crate) fn poll_controllers(mut input: ResMut) { } } -fn publish_actions(mut input: ResMut) { +fn publish_actions( + mut input: ResMut, + mut published: ResMut, +) { input.publish_actions(); + published.0 = input.tick_input(); } diff --git a/crates/skate-game/src/input/controllers.rs b/crates/skate-game/src/input/controllers.rs index d4330cd3..ba08ca9e 100644 --- a/crates/skate-game/src/input/controllers.rs +++ b/crates/skate-game/src/input/controllers.rs @@ -8,6 +8,7 @@ use skate_core::input::{ history::{DEVICE_SLOTS, HistoryRecord, PadHistory}, pad::Pad, xbox, + tick::TickInput, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -29,6 +30,7 @@ pub(crate) struct ControllerInput { pub mapped_actions: [[f32; 18]; DEVICE_SLOTS], pub publications: u64, pub consumed_batches: u64, + tick: u64, } impl Default for ControllerInput { @@ -44,6 +46,7 @@ impl Default for ControllerInput { mapped_actions: [[0.0; 18]; DEVICE_SLOTS], publications: 0, consumed_batches: 0, + tick: 0, } } } @@ -64,6 +67,22 @@ impl ControllerInput { let device = self.status.iter().position(|status| *status == ControllerStatus::Ready).unwrap_or(0); GameplayActions::from_pad(&self.pads[device]) } + + pub(super) fn tick_input(&self) -> TickInput { + let device = self + .status + .iter() + .position(|status| *status == ControllerStatus::Ready); + let controller_available = device.is_some(); + let actions = device + .map(|device| GameplayActions::from_pad(&self.pads[device])) + .unwrap_or_else(|| GameplayActions::from_values([0.0; 18])); + TickInput::new( + self.tick, + actions, + controller_available, + ) + } /// TU3 8296D288/8296D0D0: write the inactive cache, publish one four-device /// batch. Even an unchanged platform packet is sampled; packet-number /// deduplication would alter native Pad edge/repeat behavior. @@ -101,6 +120,7 @@ impl ControllerInput { /// this snapshot for subsequent consumers; Derived timers require the /// actual actor timestep and state flags and are not driven by render dt. pub(super) fn publish_actions(&mut self) -> bool { + self.tick = self.tick.wrapping_add(1); if !self.history.drain_to_latest(&mut self.pads) { return false; } diff --git a/crates/skate-game/src/input/gesture_catalog.rs b/crates/skate-game/src/input/gesture_catalog.rs index b37b2c91..848b9bde 100644 --- a/crates/skate-game/src/input/gesture_catalog.rs +++ b/crates/skate-game/src/input/gesture_catalog.rs @@ -1,7 +1,6 @@ //! Original TU3 gesture membership,82BA10C0 ->82BA07F0. //! The catalog is constructed by82B98DB8 ->82B98F70, independently checked //! against all270 raw map-insertion calls. PAT paths do not define these groups. -use skate_core::graph::intents::IntentMap; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Group { @@ -31,7 +30,7 @@ impl Group { ///82BA07F0 tests map-key presence, never the intent value. All seven maps ///contain the same30 unrotated keys; each rotated map adds its15 own keys. - pub(crate) fn has_intent(self, action_intents: &IntentMap) -> bool { + pub(crate) fn has_intent(self, has: impl Fn(&str) -> bool) -> bool { let additional: &[&str] = match self { Self::Square | Self::Nose | Self::Tail => &[], Self::Nose90 => &NOSE_90, @@ -42,7 +41,7 @@ impl Group { COMMON .iter() .chain(additional) - .any(|name| action_intents.contains_key(name)) + .any(|name| has(name)) } } diff --git a/crates/skate-game/src/input/gesture_mapping.rs b/crates/skate-game/src/input/gesture_mapping.rs index 07220159..cc7db990 100644 --- a/crates/skate-game/src/input/gesture_mapping.rs +++ b/crates/skate-game/src/input/gesture_mapping.rs @@ -61,16 +61,19 @@ impl State { let Some(name) = select(group, ag, mirrored) else { return; }; - let name = override_name.unwrap_or(name); - self.selected = Some(name.to_owned()); - self.successor = match name { + let mut name = override_name.unwrap_or(name).to_owned(); + if ag.contains_key("Underflip") { + name = format!("U_{name}"); + } + self.successor = match name.as_str() { "Kickflip" => Some("KickflipHold"), "Heelflip" => Some("HeelflipHold"), "N_Kickflip" => Some("N_KickflipHold"), "N_Heelflip" => Some("N_HeelflipHold"), _ => None, }; - mg.insert(name, 0.0); + self.selected = Some(name.clone()); + mg.insert(&name, 0.0); } pub fn update(&mut self, mg: &mut IntentMap) { if !self.first_update { @@ -82,6 +85,7 @@ impl State { self.first_update = false; } pub fn end(&mut self, mg: &mut IntentMap) { + mg.remove("DarkCatch"); if let Some(name) = &self.selected { if let Some(successor) = self.successor { mg.remove(successor); diff --git a/crates/skate-game/src/input/tests/controllers.rs b/crates/skate-game/src/input/tests/controllers.rs index 5b191989..3387d983 100644 --- a/crates/skate-game/src/input/tests/controllers.rs +++ b/crates/skate-game/src/input/tests/controllers.rs @@ -110,3 +110,37 @@ fn alternate_guitar_subtype_uses_verified_conversion_mask_and_errors_stay_visibl ); assert_eq!(input.mapped_actions[1], [0.0; 18]); } + +#[test] +fn tick_input_is_the_single_fixed_tick_snapshot() { + let mut input = ControllerInput::default(); + input.collect(std::array::from_fn(|_| packet(1, 0, [19661, 0]))); + assert!(input.publish_actions()); + + let first = input.tick_input(); + assert_eq!(first.tick(), 1); + assert!(first.controller_available()); + let mut first_actions = first.actions(); + let first_turn = first_actions.value(64); + + // A second consumer reads the same published tick without touching the + // platform cache or remapping the controller. + let second = input.tick_input(); + let mut second_actions = second.actions(); + assert_eq!(second.tick(), first.tick()); + assert_eq!(second_actions.value(64), first_turn); +} + +#[test] +fn unavailable_controllers_publish_zero_gameplay_actions() { + let mut input = ControllerInput::default(); + input.collect(std::array::from_fn(|_| packet(1, 0x1000, [32767, 0]))); + assert!(input.publish_actions()); + assert!(input.tick_input().controller_available()); + + input.collect(std::array::from_fn(|_| Err(DeviceError::Disconnected))); + assert!(input.publish_actions()); + let snapshot = input.tick_input(); + assert!(!snapshot.controller_available()); + assert_eq!(*snapshot.actions().values(), [0.0; 18]); +} diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index ae6cb01a..c5b43608 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -80,6 +80,7 @@ pub(crate) struct GamePhysics { pub ticks: u64, pub contact_count: usize, pub failed: bool, + exchange: SimulationExchange, /// ProcessedPhysIn reset82BF9EF0 sets0x2000; initial stancebit20 is clear. /// Animation packet publication owns subsequent stance-bit updates. pub processed_flags_2468: u32, @@ -87,6 +88,85 @@ pub(crate) struct GamePhysics { pub board_wiping_out: bool, } +/// Cross-phase records for the current fixed tick. Subsystems retain their +/// private native-shaped storage; only these buffers cross the coordinator. +pub(crate) struct SimulationExchange { + commands: skate_core::physics::phase::PhysicsCommandBuffer, + events: skate_core::physics::phase::PhysicsEventBuffer, + physical_output: Option, +} + +impl SimulationExchange { + fn new(tick: u64) -> Self { + Self { + commands: skate_core::physics::phase::PhysicsCommandBuffer::new(tick), + events: skate_core::physics::phase::PhysicsEventBuffer::new(tick), + physical_output: None, + } + } + + fn emit_event( + &mut self, + tick: u64, + event: skate_core::physics::phase::PhysicsEvent, + ) -> Result<(), String> { + self.events.emit(tick, event) + } + + fn publish_output(&mut self, output: skate_core::physics::phase::PhysicalOutputSnapshot) { + self.physical_output = Some(output); + } + + fn output(&self) -> Option<&skate_core::physics::phase::PhysicalOutputSnapshot> { + self.physical_output.as_ref() + } + + fn events(&self) -> &[skate_core::physics::phase::PhysicsEvent] { + self.events.events() + } + + pub(super) fn request_state( + &mut self, + state: skate_core::player::state::PhysicalStateId, + ) -> Result<(), String> { + let tick = self.commands.tick(); + self.commands.push( + tick, + skate_core::physics::phase::PhysicsCommand::RequestState(state), + ) + } +} + +#[cfg(test)] +mod exchange_tests { + use super::*; + + #[test] + fn exchange_keeps_events_on_the_authoritative_tick() { + let mut exchange = SimulationExchange::new(8); + assert!(exchange.emit_event( + 7, + skate_core::physics::phase::PhysicsEvent::StateChanged { + from: skate_core::player::state::PhysicalStateId::PhysicsGround, + to: skate_core::player::state::PhysicalStateId::PhysicsAir, + }, + ).is_err()); + assert!(exchange.emit_event( + 8, + skate_core::physics::phase::PhysicsEvent::StateChanged { + from: skate_core::player::state::PhysicalStateId::PhysicsGround, + to: skate_core::player::state::PhysicalStateId::PhysicsAir, + }, + ).is_ok()); + assert!(exchange + .request_state(skate_core::player::state::PhysicalStateId::PhysicsAir) + .is_ok()); + assert_eq!(exchange.commands.tick(), 8); + assert_eq!(exchange.commands.commands().len(), 1); + assert_eq!(exchange.events().len(), 1); + } +} + impl GamePhysics { pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) { // Actor publication carries this selector into the next physical packet. @@ -157,6 +237,7 @@ impl GamePhysics { ticks: 0, contact_count: 0, failed: false, + exchange: SimulationExchange::new(0), processed_flags_2468, board_wiping_out: false, }) @@ -216,7 +297,7 @@ fn advance( mut skater: ResMut, mut controls: ResMut, graphs: Res, - input: Res, + input: Res, mut camera: ResMut, mut cadence: ResMut>, mut exit: MessageWriter, @@ -226,11 +307,8 @@ fn advance( return; } let timer = performance.as_ref().map(|_| std::time::Instant::now()); - let mut actions = input.player_actions(); - let input_available = input - .status - .iter() - .any(|s| *s == crate::input::ControllerStatus::Ready); + let mut actions = input.0.actions(); + let input_available = input.0.controller_available(); if let Err(message) = frame::advance( &mut physics, &mut skater, @@ -244,7 +322,7 @@ fn advance( error!( "{message}; tick={}; mapped_input={:?}; force_mode={}; board_axis_y={}; flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x}", physics.ticks, - input.mapped_actions, + input.0.actions(), skater.skeleton_input.force_mode, skater.skeleton_input.drive_frames[0][2][1], skater.player_input.processed.flags_2468, @@ -357,3 +435,7 @@ fn present( alpha); } } + +#[cfg(test)] +#[path = "tests/powerslide_playback.rs"] +mod powerslide_tests; diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs index 170e19d0..5e794057 100644 --- a/crates/skate-game/src/physics/animation_phase.rs +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -101,7 +101,9 @@ pub(crate) fn advance( handplant_flags: physical.air.handplant_flags_324, time_to_skitch: physical.ground.scalar_276, skitch_transition_time: profile.skitch_transition_time, - time_to_land: physical.animation.collision_time_144, + time_to_land: physical.air.scalar_184, + time_to_land_valid: physical.air.known_air_valid_437 != 0, + trucks_or_deck_contact: physical.collision.flag_3472 != 0 || physical.collision.flag_3475 != 0, offboard_time_to_land: physical.off_board.scalar_32, tricks_blocked_on_stairs: physical.animation.tricks_blocked_on_stairs_166 != 0, }, diff --git a/crates/skate-game/src/physics/camera_output.rs b/crates/skate-game/src/physics/camera_output.rs index ed147e77..ce3b1884 100644 --- a/crates/skate-game/src/physics/camera_output.rs +++ b/crates/skate-game/src/physics/camera_output.rs @@ -20,6 +20,13 @@ pub(crate) fn advance( feedback: &PhysicalFeedback, camera: &mut crate::camera::CameraRuntime, ) -> Result<(), String> { + let output = physics.exchange.output() + .ok_or("Camera requires the completed physical output snapshot")?; + let completed_tick = physics.ticks.checked_sub(1) + .ok_or("Camera received output before the first completed tick")?; + if output.tick != completed_tick || output.state != skater.player_state.current() { + return Err(format!("Camera received stale physical output: tick={}, completed={completed_tick}", output.tick)); + } //Once per physical publication, preserving conditioner history across //direct grind-type changes and invalidating it on the first non-grind tick. physics.grind.advance_camera( @@ -89,8 +96,11 @@ pub(crate) fn publish( let fields = &skater.animation_input.fields; let intents = skater.animation_input.output.flags; let packet = &skater.animation.packet; - let normal = physics.riding.ground.wheel_normal; + let output = physics.exchange.output() + .ok_or("Camera requires the completed physical output snapshot")?; + let normal = output.ground_normal; Ok(CameraPublicationInputs { + tick: output.tick, state: CameraStateOutput { height_32: physical.state.surface_height_32, physically_pushing_55: bit(p.flags_2468, 25), @@ -166,7 +176,7 @@ pub(crate) fn publish( skater.animation_input.extra.look_x, skater.animation_input.extra.look_y, ], - collision_look_target_64: physical.collision.predicted_position_64.map(f32::from_bits), + collision_look_target_64: [output.predicted_position.x, output.predicted_position.y, output.predicted_position.z, 0.0], preferences, context, }) diff --git a/crates/skate-game/src/physics/controls.rs b/crates/skate-game/src/physics/controls.rs index 7e74b915..44c2fc6a 100644 --- a/crates/skate-game/src/physics/controls.rs +++ b/crates/skate-game/src/physics/controls.rs @@ -1,6 +1,6 @@ //! Controller-derived state is evaluated on the same fixed gameplay tick. use super::{GamePhysics, skater::SkaterRuntime}; -use crate::input::ControllerInput; +use crate::input::PublishedTickInput; use bevy::prelude::*; use skate_core::graph::intents::IntentMap; use skate_core::input::{ @@ -42,13 +42,13 @@ impl Default for PlayerControls { } pub(super) fn sample( - input: Res, + input: Res, physics: Res, skater: Res, camera: Res, mut player: ResMut, ) { - let mut map = input.player_actions(); + let mut map = input.0.actions(); player.update( &mut map, physics.settings.step.simulation.time_step, @@ -103,6 +103,11 @@ impl PlayerControls { )); self.intents.extend(skate_core::input::anticipation_intentions::produce(&self.controller)); self.intents.extend(skate_core::input::grind_intentions::produce(&self.controller)); + self.intents.extend( + skate_core::input::trick_intentions::produce(&self.controller) + .into_iter() + .map(|intent| RidingIntent { name: intent.name, value: intent.value }), + ); //GenerateActionGraphIntents82594310 clears the AG map through82BC1B68 //before Listener::Fill. MG lifecycle intents use a different persistent map. self.action_intents.clear(); diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 2ff24b86..bfb99b42 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -5,7 +5,7 @@ use super::{ input_phase, player_state, solve, }; use crate::{camera::CameraRuntime, graph_runtime::StockGraphs}; -use skate_core::{input::controller::ActionMap, math::Vector3}; +use skate_core::{input::controller::ActionMap, math::Vector3, physics::{board::BodyId, phase::{PhysicsEvent, PhysicalOutputSnapshot}}}; pub(super) fn advance( physics: &mut GamePhysics, @@ -16,6 +16,8 @@ pub(super) fn advance( input_available: bool, camera: &mut CameraRuntime, ) -> Result<(), String> { + let tick = physics.ticks; + physics.exchange = super::SimulationExchange::new(tick); //SimController8285C968 dispatches the preceding tick's camera messages //before simulation. Keep End/Begin ordering when both occur in one update. for request in camera.simulation_rate_requests.drain(..) { @@ -85,7 +87,18 @@ pub(super) fn advance( } //World8275EC0C ends board queries before PostInput/state selection. physics.riding.finish_wheel_queries()?; + let state_before_selection = skater.player_state.current(); player_state::post_input_and_select(physics, skater)?; + let state_after_selection = skater.player_state.current(); + if state_before_selection != state_after_selection { + physics.exchange.emit_event( + tick, + PhysicsEvent::StateChanged { + from: state_before_selection, + to: state_after_selection, + }, + )?; + } player_state::pre_state(physics, skater)?; match skater.player_state.current() { skate_core::player::state::PhysicalStateId::GrindBoardslide @@ -173,6 +186,38 @@ pub(super) fn advance( physical.reckoning.vector_16.map(f32::from_bits), ); let feedback = animation_phase::publish_feedback(physics, skater); + let deck = physics.board.bodies()[BodyId::Deck.index()]; + let rider = skater + .skeleton + .bodies() + .first() + .ok_or("Physical output requires the constructed rider body")?; + let ground_normal = physics.riding.ground.wheel_normal; + let predicted = skater.player_input.physical.collision.predicted_position_64; + let predicted_position = Vector3::new( + f32::from_bits(predicted[0]), + f32::from_bits(predicted[1]), + f32::from_bits(predicted[2]), + ); + let events = physics.exchange.events().to_vec(); + physics.exchange.publish_output(PhysicalOutputSnapshot { + tick, + state: skater.player_state.current(), + board_position: deck.rates.position, + board_linear_velocity: deck.rates.linear_velocity, + rider_root_position: rider.rates.position, + rider_linear_velocity: rider.rates.linear_velocity, + ground_normal, + contact_count: physics.riding.ground.part_contact_count as u32, + predicted_position, + grounded: matches!( + skater.player_state.current(), + skate_core::player::state::PhysicalStateId::PhysicsGround + ), + wiping_out: skater.skeleton_collision.is_ragdoll, + landed: events.iter().any(|event| matches!(event, PhysicsEvent::Landing)), + events, + }); camera_output::advance(physics, skater, &feedback, camera)?; skater.animation_input.finish_output_publication(); skater.player_input.player.update_count_1316 = diff --git a/crates/skate-game/src/physics/player_input/mod.rs b/crates/skate-game/src/physics/player_input/mod.rs index 1fb71897..055cc3a1 100644 --- a/crates/skate-game/src/physics/player_input/mod.rs +++ b/crates/skate-game/src/physics/player_input/mod.rs @@ -70,6 +70,9 @@ impl PlayerInputRuntime { pub fn pending_teleport(&self) -> Option { self.pending_teleport } + pub fn processed_snapshot(&self, tick: u64) -> skate_core::player::input_phase::ProcessedPhysicsSnapshot { + skate_core::player::input_phase::ProcessedPhysicsSnapshot::new(tick, self.processed) + } ///Run after the current board/contact solve, before ProcessOutput publishes. pub fn update_dynamic_normal( &mut self, diff --git a/crates/skate-game/src/physics/player_state.rs b/crates/skate-game/src/physics/player_state.rs index 45893cae..e5595133 100644 --- a/crates/skate-game/src/physics/player_state.rs +++ b/crates/skate-game/src/physics/player_state.rs @@ -110,7 +110,7 @@ pub(crate) fn post_input_and_select( ) -> Result<(), String> { super::grind::query(physics, skater); post_input::advance(physics, skater)?; - selection::advance(physics, skater) + selection::advance(physics, skater, skater.player_input.processed_snapshot(physics.ticks)) } ///The reset/board/skeleton publications precede this selected-state FillPhysOut. pub(crate) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { diff --git a/crates/skate-game/src/physics/player_state/selection.rs b/crates/skate-game/src/physics/player_state/selection.rs index 5e7530bc..6733524f 100644 --- a/crates/skate-game/src/physics/player_state/selection.rs +++ b/crates/skate-game/src/physics/player_state/selection.rs @@ -4,8 +4,13 @@ use skate_core::player::selector::{ ProcessedStateInput, StateSelectionInput, conditions::{BoardBodyState, SkeletonAnimationState}, }; -pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { - let p = &skater.player_input.processed; +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + snapshot: skate_core::player::input_phase::ProcessedPhysicsSnapshot, +) -> Result<(), String> { + debug_assert_eq!(snapshot.tick, physics.ticks); + let p = &snapshot.input; let input = StateSelectionInput { processed: ProcessedStateInput { //The retained contact owner publishes native kind and candidate384. diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index e3d5d160..e07127d3 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -6,6 +6,7 @@ use crate::{ graph_host::{ action::ActionHost, motion::{MotionHost, MotionPhysical}, + outputs::{ActionGraphInput, ActionInput, MotionGraphInput, PriorMotionState}, }, graph_runtime::StockGraphs, }; @@ -158,33 +159,28 @@ impl SkaterAnimation { physical: AnimationPhysical, reset_fields: &mut AdditionalResetFields, ) -> Result<(), String> { + let tick = self.ticks; self.publish_physical(physical)?; - self.action.action_intents.clone_from(action_intents); - self.motion.action_intents.clone_from(action_intents); - //HasAnimAttribute82BA3A78 reads the preceding cached animation - //attributes. Specific flag27 is observed before this frame's MG update. - self.action.animation_attributes.clear(); - self.action - .animation_attributes - .extend_from_slice(self.motion.animation.tree_attributes()); + self.action.prepare_input(ActionGraphInput { + tick, + controls: ActionInput::from_values(action_intents), + prior_motion: PriorMotionState::from_values(&self.motion.animation.motion_intents), + //HasAnimAttribute82BA3A78 reads the preceding cached animation + //attributes. Specific flag27 is observed before this frame's MG update. + animation_attributes: self.motion.animation.tree_attributes().to_vec(), + }); self.action.is_tricking = Some(self.motion.flags.doing_trick); - // Specific MotionGraph+8 is its own persistent filtered-intent map - // (82D0B660), initially cleared by825953B0. Only authored filtering - // behaviors write it; raw actor/controller intents do not replace it. - std::mem::swap( - &mut self.action.motion_intents, - &mut self.motion.animation.motion_intents, - ); self.action_controller .update(&graphs.action.runtime.program, dt, &mut self.action); - std::mem::swap( - &mut self.action.motion_intents, - &mut self.motion.animation.motion_intents, - ); if !self.action.errors.is_empty() { return Err(self.action.errors.join("\n")); } + let action_output = self.action.output(); self.motion.animation.begin_graph_update(); + self.motion.accept_action_graph(MotionGraphInput { + tick: action_output.tick, + action: action_output, + }); self.motion_controller .update(&graphs.motion.runtime.program, dt, &mut self.motion); if !self.motion.errors.is_empty() { diff --git a/crates/skate-game/src/tests/action_host.rs b/crates/skate-game/src/tests/action_host.rs index 32fcf1c2..ae67b769 100644 --- a/crates/skate-game/src/tests/action_host.rs +++ b/crates/skate-game/src/tests/action_host.rs @@ -8,6 +8,8 @@ fn parameter(mg_intent: &str, value: f32) -> Parameter { Parameter { name: None, mg_intent: Some(mg_intent.into()), + mg_intent_mag: None, + mg_intent_angle: None, ag_intent: None, text: None, float_bits: Some(value.to_bits()), @@ -16,6 +18,8 @@ fn parameter(mg_intent: &str, value: f32) -> Parameter { scale: None, on_update: false, filters: [0; 4], + angle_filter: 0, + negate_on_mirror: false, } } @@ -94,6 +98,34 @@ fn const_callbacks_publish_then_remove_motion_intent() { assert!(!host.motion_intents.contains_key("Crouch")); } +#[test] +fn released_grab_source_cannot_retain_its_motion_intent() { + let mut config = parameter("WantsLeftAirGrab", 0.0); + config.ag_intent = Some("LeftAirGrab".into()); + config.on_update = true; + let instance = ActionInstance { + operation: ActionOperation::CreateMgIntent, + config, + parameters: Vec::new(), + }; + let mut host = ActionHost::new( + ActionInstances::new(vec![instance]), + OperationRemap { + behaviors: vec![0], + conditions: vec![], + hooks: vec![], + }, + ); + host.motion_intents.insert("WantsLeftAirGrab", 1.0); + Host::update(&mut host, 0, [0; 6], &Frame { + dt: 1.0 / 60.0, + current: None, + last: None, + state_times: Vec::new(), + }); + assert!(!host.motion_intents.contains_key("WantsLeftAirGrab")); +} + #[test] fn pushing_clock_starts_fresh_when_graph_reenters_behavior() { let mut config = parameter("Pushing", 0.0); @@ -195,13 +227,13 @@ fn native_intent_aliases_share_action_motion_and_end_lifecycle() { host.begin(1, [0; 6], &frame); assert_eq!(host.motion_intents.len(), 1); - // Preserve the production coordinator's single-map handoff. - std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + // Preserve the production coordinator's explicit graph handoff. + let output = host.output(); + motion.accept_action_graph(output); assert_eq!(motion.motion_intent(first), Some(1.25)); assert_eq!(motion.motion_intent(second), Some(1.25)); - std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); host.end(0, [0; 6], &frame); - std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + motion.accept_action_graph(host.output()); assert_eq!(motion.motion_intent(first), None); assert_eq!(motion.motion_intent(second), None); assert!(motion.motion_intents.is_empty()); diff --git a/crates/skate-game/src/tests/air_playback.rs b/crates/skate-game/src/tests/air_playback.rs index c1ffddaa..485cedcc 100644 --- a/crates/skate-game/src/tests/air_playback.rs +++ b/crates/skate-game/src/tests/air_playback.rs @@ -17,6 +17,30 @@ fn airborne_stick_reaches_physical_spin() { } fn roll_off(air_stick: Option) { + roll_off_input(air_stick, None, [0; 2]); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn airborne_grabs_release_through_stock_graphs() { + for hand in 0..2 { + for stick in [[0, 0], [32767, 0], [-32767, 0], [0, 32767], [0, -32767]] { + roll_off_input(None, Some(hand), stick); + } + } +} + +fn roll_off_input(air_stick: Option, grab_hand: Option, grab_stick: [i16; 2]) { + roll_off_sequence(air_stick, grab_hand, grab_stick, false); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn airborne_tailwalk_from_raw_controller_reaches_stock_cycle() { + roll_off_sequence(None, Some(0), [0, 32767], true); +} + +fn roll_off_sequence(air_stick: Option, grab_hand: Option, grab_stick: [i16; 2], tailwalk: bool) { 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(); @@ -28,6 +52,21 @@ fn roll_off(air_stick: Option) { .filter(|face| !(face.tag == 1 && face.triangle.vertices.iter().any(|vertex| vertex.z > 4.0))) .collect()); + if grab_hand.is_some() { + // A deeper test landing gives the held cycle and OUT animation time + // to run before contact can hide a failed airborne release. + physics.world = BoardWorld::new(physics.world.triangles().iter().map(|face| { + let vertices = face.triangle.vertices.map(|mut vertex| { + if vertex.y < -1.0 { vertex.y -= 18.0; } + vertex + }); + skate_core::physics::board_world::WorldTriangle::from_vertices( + vertices, face.material, face.tag, + skate_core::physics::collision::TriangleFeature::ONE_SIDED, + [1.0; 3], 0.0, + ).unwrap() + }).collect()); + } let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut input = crate::input::ControllerInput::default(); @@ -36,12 +75,23 @@ fn roll_off(air_stick: Option) { let mut saw_air = false; let mut landed_frames = 0; let mut airborne_frames = 0; + let mut grab_frames = 0; + let mut saw_grab = false; + let mut saw_release = false; for tick in 0..1000 { + let mut triggers = [0; 2]; + if let Some(hand) = grab_hand { + if saw_air && grab_frames < if tailwalk { 100 } else { 35 } { + triggers[hand] = 255; + } + } input.sample_raw_for_test(skate_core::input::xbox::XboxState { - buttons: if tick >= 12 && !saw_air { 0x1000 } else { 0 }, - triggers: [0; 2], + buttons: if tick >= 12 && !saw_air { 0x1000 } + else if tailwalk && saw_air && grab_frames >= 20 { 0x2000 } + else { 0 }, + triggers, left: [if saw_air { air_stick.unwrap_or(0) } else { 0 }, 0], - right: [0; 2], + right: if triggers != [0; 2] { grab_stick } else { [0; 2] }, }); let mut actions = input.player_actions(); controls.update( @@ -80,6 +130,37 @@ fn roll_off(air_stick: Option) { ) }); saw_air |= state.category() == 200; + if tailwalk && state.category() == 200 { + let motion = &skater.animation.motion; + if grab_frames >= 20 { + eprintln!("Tailwalk frame={grab_frames} LeftPush={:?} RightPush={:?} angle={:?} TailGrab={:?} NoFootAirWalk={:?} animation={:?}", + controls.action_intents.get("LeftPush"), controls.action_intents.get("RightPush"), + controls.action_intents.get("BoardAdjustAngle"), + motion.animation.motion_intents.get("TailGrab"), motion.animation.motion_intents.get("NoFootAirWalk"), + motion.animation.current_name); + } + if motion.animation.current_name.as_deref() == Some("2FT_AIR_GRAB_N_TAIL_0_CYC") { + assert!(motion.animation.motion_intents.contains_key("TailGrab")); + assert!(motion.animation.motion_intents.contains_key("NoFootAirWalk")); + assert_eq!(motion.score_packet.grab.map(|(name, _)| name), + Some(skate_core::animation::skeleton_input::name::encode(b"tailgrab_airwalk"))); + assert_eq!(skater.pose_generation, physics.ticks); + return; + } + } + if grab_hand.is_some() && state.category() == 200 { + grab_frames += 1; + let anim = &skater.animation.motion.animation; + let grabbing = skater.player_input.processed.flags_2468 & 0x20 != 0; + saw_grab |= grabbing; + saw_release |= saw_grab && grab_frames > 35 && !grabbing; + if matches!(grab_frames, 3 | 34 | 36 | 50 | 65) { + eprintln!("Grab hand={grab_hand:?} stick={grab_stick:?} frame={grab_frames} trigger={triggers:?} AG={:?}/{:?} MG={:?}/{:?} animation={:?} flags={:08x}", + controls.action_intents.get("LeftAirGrab"), controls.action_intents.get("RightAirGrab"), + anim.motion_intents.get("WantsLeftAirGrab"), anim.motion_intents.get("WantsRightAirGrab"), + anim.current_name, skater.player_input.processed.flags_2468); + } + } if saw_air && air_stick.is_some() { airborne_frames += 1; eprintln!( @@ -127,6 +208,11 @@ fn roll_off(air_stick: Option) { } } assert!(saw_air, "Raw pushing never entered the actual Air family"); + assert!(!tailwalk, "Raw airborne LT + right stick up + B did not produce the Tailwalk cycle"); + if grab_hand.is_some() { + assert!(saw_grab, "Grab never became active"); + assert!(saw_release, "Grab never released while airborne"); + } assert!( landed_frames >= 120, "Air never returned to stable grounded riding" @@ -171,4 +257,4 @@ fn stock_starting_ramp_reaches_floor_without_air() { } } panic!("Did not reach the lower floor within 600 ticks"); -} \ No newline at end of file +} diff --git a/crates/skate-game/src/tests/powerslide_playback.rs b/crates/skate-game/src/tests/powerslide_playback.rs new file mode 100644 index 00000000..e631d01e --- /dev/null +++ b/crates/skate-game/src/tests/powerslide_playback.rs @@ -0,0 +1,103 @@ +//! Raw controller -> stock graphs -> canonical packet -> SlideGround regression. +use super::*; +use skate_core::{animation::skeleton_input::name::encode, player::state::PhysicalStateId}; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_controller_powerslide_uses_stock_graph_and_releases_both_sides() { + for side in [-1_i16, 1] { + run_side(side); + } +} + +fn run_side(side: i16) { + 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 data = skate_data::collections::Collections::load(root).unwrap(); + let entry_speed = data.float("anim_motion", "power_slide", "slide_speed_threshold").unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + // Test phases only choose controller packets. Physics and graph state are + // never overwritten, and the production world remains intact. + let mut sequence_start = None; + let mut saw_push = false; + let mut saw_start = false; + let mut saw_slide = false; + let mut saw_packet = false; + let mut release_clear = false; + for tick in 0..900 { + if sequence_start.is_none() && tick >= 60 + && physics.riding.motion.ground_speed > entry_speed * 1.5 + && skater.player_state.current() == PhysicalStateId::PhysicsGround + { + sequence_start = Some(tick); + } + let phase = sequence_start.map(|start| tick - start); + let left = match phase { + Some(12..=23) => [side * 32767, 0], + Some(36..=95) => [side * 22000, 32767], + _ => [0; 2], + }; + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick >= 12 && sequence_start.is_none() { 0x1000 } else { 0 }, + triggers: [0; 2], left, right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update(&mut actions, physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204); + frame::advance(&mut physics, &mut skater, &mut controls, &graphs, + &mut actions, true, &mut camera).unwrap_or_else(|error| panic!( + "Powerslide side={side} tick={tick} phase={phase:?}: {error}; state={:?}; speed={}; deck={:?}", + skater.player_state.current(), physics.riding.motion.ground_speed, + physics.board.bodies()[6].rates)); + saw_push |= skater.player_input.processed.flags_2468 & (1 << 25) != 0; + let motion = &skater.animation.motion; + let starts = motion.slide_latch.start(true) || motion.slide_latch.start(false); + if matches!(phase, Some(12..=23)) { + assert!(controls.action_intents.get("RightSlideStart").is_none() + && controls.action_intents.get("LeftSlideStart").is_none(), + "Pure lateral stick generated a slide start: side={side} tick={tick}"); + assert!(!starts, "Pure lateral stick started the slide latch"); + assert_ne!(skater.player_state.current(), PhysicalStateId::SlideGround, + "Pure lateral stick entered SlideGround"); + } + if matches!(phase, Some(36..=95)) { + saw_start |= starts; + saw_slide |= skater.player_state.current() == PhysicalStateId::SlideGround; + let emitted = motion.animation.motion_attributes.iter() + .any(|attribute| attribute.name == encode(b"slide") && attribute.value != 0.0); + let published = skater.animation.attributes.entries().iter().rev() + .find(|attribute| attribute.name == encode(b"slide") && matches!(attribute.kind, 0 | 2)) + .and_then(|attribute| attribute.payload.0[0]).map(f32::from_bits); + if emitted && let Some(value) = published { + assert_eq!(skater.animation_input.fields.slide.to_bits(), value.to_bits(), + "Slide changed between canonical animation packet and Skeleton input"); + saw_packet |= value != 0.0; + } + } + if matches!(phase, Some(97..)) { + for name in ["LeftSlideStart", "RightSlideStart", "LeftSlide", "RightSlide"] { + assert!(controls.action_intents.get(name).is_none(), "Released AG intent retained: {name}"); + assert!(motion.animation.motion_intents.get(name).is_none(), "Released MG intent retained: {name}"); + } + release_clear |= !motion.is_power_sliding + && skater.player_state.current() != PhysicalStateId::SlideGround; + } + assert!(camera.frame.is_some()); + assert_eq!(skater.pose_generation, physics.ticks); + if phase.is_some_and(|phase| phase >= 215) { break; } + } + assert!(sequence_start.is_some(), "Real pushing never attained stock slide speed: side={side}, speed={}, threshold={entry_speed}", physics.riding.motion.ground_speed); + assert!(saw_push, "No physical push was published"); + assert!(saw_start, "Diagonal input never started the stock slide latch: side={side}"); + assert!(saw_packet, "No canonical nonzero slide packet reached Skeleton: side={side}"); + assert!(saw_slide, "Stock graph never entered SlideGround: side={side}"); + assert!(release_clear, "Slide failed to exit after controller release: side={side}"); + eprintln!("Powerslide side={side}: stock entry, packet, physical SlideGround and release passed in {} ticks", physics.ticks); +} diff --git a/docs/archive-crates-3-merge.md b/docs/archive-crates-3-merge.md new file mode 100644 index 00000000..e0d3c2bd --- /dev/null +++ b/docs/archive-crates-3-merge.md @@ -0,0 +1,77 @@ +# Merge of supplied crates (3).zip + +Source: `C:/Users/Daddy/Downloads/crates (3).zip`, SHA256 +`beb4aabf83fe706b6678d78f4af10c85a6467e0fef1c3c2a8d92e922405134ad`. +The archive contains a source snapshot without Git history. It was compared with +the initial import (`a47da5b`), current HEAD (`0909877`), and the working tree. +Files absent from the archive were not treated as deletions. + +## Integrated + +- Powerslide start and continuous input publication; grab, dark-catch, board + adjustment and tweak inputs; underflip gesture naming and dark-catch cleanup. +- ActionGraph BoardAdjust forwarding with authored angle/mirror filters. +- Grab type ownership, grab scoring, filtered tweak projection, EndGesture + teardown, and moving-object resource registration. Registration does not add + moving world geometry or skitch physics. +- Immutable fixed-tick controller input, typed ActionGraph/MotionGraph handoffs, + bounded graph diagnostics, processed-input snapshots and physical output/event + records. These wrap the current simulation owners and preserve their order. +- Camera snapshot tick validation, publication diagnostics and graph argument + validation. Current subject selection, compass/orbit math, grind conditioning + and render interpolation remain in place; no camera smoothing was added. +- The archive's on-board TimeToLand mapping to Air184, gated by Air437. +- Associated supplied input/action/air/powerslide test sources were retained; + they were not executed. + +## Conflict decisions + +The user's current offboard implementation takes precedence, including walking, +jumping, falling, runout, possession, mounting/dismounting and climbing. The 135 +existing files under the offboard/climbing/input-offboard paths and the camera +compass/subject-pose modules are byte-identical to the pre-merge backup. Shared +graph and physics files keep those same owner calls and physical publications. + +The archive's alternative Biped states, collision toolkit, possession drives, +offboard graph handlers and offboard-related ragdoll/IK rewrites were excluded. +Existing ground/wipeout/reset APIs were retained rather than applying its removal +of methods now used by the current implementation. Uncommitted work present +before the merge was preserved and is not included in this commit. + +Our later native blend-space/bump implementation, raw selection-space child +construction, grind acquisition/families/release, JumpInto, manuals and dark-catch +selection take precedence over the archive's older or approximate alternatives. +Its broad placeholder stock-condition module and unimplemented behavior variants +were not registered over existing handlers. Existing unsupported branches still +report errors when reached; optional unsupported branches do not prevent startup. + +## Grab conflict corrections checked against TU3 + +The archive's blanket CurrentGrabType predicate was replaced by the actual enum +comparison: condition 82BA7848 calls ISkaterAnim+100, implemented by 82B97200. +SetGrabType Begin82BB9CB8/End82BB9D20 call setter82B971F8 with the selected enum/0. +This writes ISkaterAnim360; it does not emit a synthetic `GrabType` physics +attribute. FS/BS/Nose/Tail are 1/2/3/4, as decoded by constructor82BA7760. + +IsCrouchedEnoughForBlendToGrabCycle82BBDE40 compares PhysOutSkeleton72 strictly +below constant821EE79C (`0x3f19999a`, 0.6), replacing the archive's `<= 0.5`. +Evidence was read from the generated PPC and the original executable image +`default_82000000_011B0000.bin` (base82000000, SHA256 +`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`). + +HasTweak82BA6B90 uses the filtered TweakX/Y signals, requires at least one +component to exist, and applies the authored numeric comparison to their vector +magnitude. TrucksOrDeckInContact82BA42E0 reads Collision3472 OR3475 (truck/deck +contact), rather than the archive's wheel/grind approximation. These conditions +were connected after the first launched build reached them and reported an +unsupported-condition error during user input. + +## Validation and recovery + +`cargo build -p skate-game --locked` succeeds. No automated gameplay or test +suite was run. This is an integration build, not proof of full native gameplay +parity; user playtesting is still required. + +Local recovery artifacts are `logs/pre-crates-3-merge.zip`, the untouched extracted +archive in `logs/incoming-crates-3`, comparison reports in `logs/incoming-*.json`, +and `logs/archive-merge-build.log`. These are ignored local artifacts.