Merge supplied skating controls and graph handoffs, preserve current offboard

This commit is contained in:
chazmm committed 2026-09-07 22:52:57 +01:00
1 parent 09098773de
commit 203aeaf8bb
56 files changed
+1987 -127

No files matched your search

+1 -1
View File
@@ -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
+2
View File
@@ -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,
});
}
}
+36
View File
@@ -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}");
}
}
}
+1
View File
@@ -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;
+106
View File
@@ -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 {
+59
View File
@@ -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);
}
}
+2 -4
View File
@@ -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));
}
}
+18 -8
View File
@@ -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,
+25 -2
View File
@@ -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)
+18 -12
View File
@@ -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)
}
}
+99 -6
View File
@@ -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,
}
}
+3
View File
@@ -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;
+37 -2
View File
@@ -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"));
}
}
+305
View File
@@ -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());
}
}
+20 -1
View File
@@ -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]);
}
+89 -7
View File
@@ -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,
},
+12 -2
View File
@@ -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,
})
+8 -3
View File
@@ -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();
+46 -1
View File
@@ -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.
+15 -19
View File
@@ -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() {
+36 -4
View File
@@ -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());
+90 -4
View File
@@ -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);
}
+77
View File
@@ -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.