mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Merge supplied skating controls and graph handoffs, preserve current offboard
This commit is contained in:
1 parent
09098773de
commit
203aeaf8bb
56 files changed
+1987
-127
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<TrickIntent> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PhysicsCommand> }
|
||||
|
||||
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<PhysicsEvent> }
|
||||
|
||||
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<PhysicsEvent>,
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::<CameraSubjectFrame>()
|
||||
.add_systems(Startup, spawn)
|
||||
app.add_systems(Startup, spawn)
|
||||
.add_systems(Update, present.after(FrameSet::Animation).before(FrameSet::Verification));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -26,7 +26,7 @@ impl Condition {
|
||||
pub fn parse(a: &Attributes<'_>) -> Result<Self, String> {
|
||||
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("") {
|
||||
|
||||
@@ -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::<Vec<_>>().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,
|
||||
|
||||
@@ -22,6 +22,10 @@ pub(crate) struct CameraRuntime {
|
||||
shakes: [ShakeSamples; 2],
|
||||
trajectories: [TrajectoryResult; 3],
|
||||
pub frame: Option<CameraFrame>,
|
||||
/// 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<CameraSubjectSnapshot>,
|
||||
/// Native message order, including End followed by Begin in one graph tick.
|
||||
/// The simulation schedule drains these after advance.
|
||||
pub simulation_rate_requests: Vec<SimulationRateRequest>,
|
||||
@@ -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<CameraFrame, String> {
|
||||
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)
|
||||
|
||||
@@ -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<CameraSubjectSnapshot>,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
pub(super) struct SubjectPublisher {
|
||||
pose: SubjectPosePublisher,
|
||||
anchors: Anchors,
|
||||
compass: Compass,
|
||||
last_tick: Option<u64>,
|
||||
}
|
||||
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<ManagerSubject, String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<skate_core::animation::output::attributes::AnimationAttribute>,
|
||||
pub is_tricking: Option<bool>,
|
||||
pub stance: Option<(bool, bool)>,
|
||||
pub errors: Vec<String>,
|
||||
tick: u64,
|
||||
pub errors: GraphDiagnostics,
|
||||
remap: OperationRemap,
|
||||
state_parents: Vec<Option<usize>>,
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,8 @@ use skate_data::state_graph::{
|
||||
pub struct Parameter {
|
||||
pub name: Option<String>,
|
||||
pub mg_intent: Option<String>,
|
||||
pub mg_intent_mag: Option<String>,
|
||||
pub mg_intent_angle: Option<String>,
|
||||
pub ag_intent: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub float_bits: Option<u32>,
|
||||
@@ -16,6 +18,8 @@ pub struct Parameter {
|
||||
pub scale: Option<f32>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
pub errors: GraphDiagnostics,
|
||||
pub(super) state_parents: Vec<Option<usize>>,
|
||||
operations: Vec<MotionOperation>,
|
||||
instances: Vec<Instance>,
|
||||
@@ -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
|
||||
|
||||
@@ -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<super::motion_stock_gameplay::GrabType>,
|
||||
metadata: AnimationMetadata,
|
||||
current: Option<PlaybackTree>,
|
||||
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<PlaybackTree, String> {
|
||||
build(
|
||||
&self.metadata,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bool, String> {
|
||||
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()
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<skate_core::animation::output::attributes::AttributeName>,
|
||||
pub name: Option<u32>,
|
||||
pub flags: u32,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<Self, String> {
|
||||
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<u32, String>,
|
||||
active: Option<u32>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<bool, String> {
|
||||
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<String>, left: Option<String>, down: Option<String>, right: Option<String>,
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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<f32> {
|
||||
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<String>,
|
||||
overflowed: bool,
|
||||
}
|
||||
|
||||
impl GraphDiagnostics {
|
||||
const CAPACITY: usize = 64;
|
||||
|
||||
pub fn push(&mut self, message: impl Into<String>) {
|
||||
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<String>,
|
||||
pub unsupported_conditions: Vec<String>,
|
||||
pub unsupported_hooks: Vec<String>,
|
||||
}
|
||||
|
||||
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<PhysicalStateId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ActionGraphInput {
|
||||
pub tick: u64,
|
||||
pub controls: ActionInput,
|
||||
pub prior_motion: PriorMotionState,
|
||||
pub animation_attributes: Vec<AnimationAttribute>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ActionGraphOutput {
|
||||
pub tick: u64,
|
||||
pub controls: ActionControls,
|
||||
pub motion_effects: MotionEffects,
|
||||
pub turning: TurningOutput,
|
||||
pub animation_attributes: Vec<AnimationAttribute>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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::<ControllerInput>()
|
||||
.init_resource::<PublishedTickInput>()
|
||||
.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<ControllerInput>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_actions(mut input: ResMut<ControllerInput>) {
|
||||
fn publish_actions(
|
||||
mut input: ResMut<ControllerInput>,
|
||||
mut published: ResMut<PublishedTickInput>,
|
||||
) {
|
||||
input.publish_actions();
|
||||
published.0 = input.tick_input();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<skate_core::physics::phase::PhysicalOutputSnapshot>,
|
||||
}
|
||||
|
||||
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<SkaterRuntime>,
|
||||
mut controls: ResMut<PlayerControls>,
|
||||
graphs: Res<crate::graph_runtime::StockGraphs>,
|
||||
input: Res<crate::input::ControllerInput>,
|
||||
input: Res<crate::input::PublishedTickInput>,
|
||||
mut camera: ResMut<crate::camera::CameraRuntime>,
|
||||
mut cadence: ResMut<Time<Fixed>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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<ControllerInput>,
|
||||
input: Res<PublishedTickInput>,
|
||||
physics: Res<GamePhysics>,
|
||||
skater: Res<SkaterRuntime>,
|
||||
camera: Res<crate::camera::CameraRuntime>,
|
||||
mut player: ResMut<PlayerControls>,
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -70,6 +70,9 @@ impl PlayerInputRuntime {
|
||||
pub fn pending_teleport(&self) -> Option<AnimationPartTransform> {
|
||||
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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -17,6 +17,30 @@ fn airborne_stick_reaches_physical_spin() {
|
||||
}
|
||||
|
||||
fn roll_off(air_stick: Option<i16>) {
|
||||
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<i16>, grab_hand: Option<usize>, 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<i16>, grab_hand: Option<usize>, 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<i16>) {
|
||||
.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<i16>) {
|
||||
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<i16>) {
|
||||
)
|
||||
});
|
||||
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<i16>) {
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user