From bdba0c0a417c96c5c0d7f15053bca94b4b594706 Mon Sep 17 00:00:00 2001 From: chazmm Date: Sun, 6 Sep 2026 17:22:51 +0100 Subject: [PATCH] Implement native runout animation attributes and reproduce the missing biped transition --- crates/skate-game/src/graph_host/mod.rs | 1 + crates/skate-game/src/graph_host/motion.rs | 4 ++ .../src/graph_host/motion_execute.rs | 15 +++++ .../skate-game/src/graph_host/motion_nodes.rs | 2 + .../src/graph_host/motion_runout.rs | 57 +++++++++++++++++++ .../skate-game/src/physics/animation_phase.rs | 9 +++ .../skate-game/src/tests/wipeout_playback.rs | 34 +++++++++++ docs/RUNOUT.md | 41 +++++++++++++ 8 files changed, 163 insertions(+) create mode 100644 crates/skate-game/src/graph_host/motion_runout.rs create mode 100644 docs/RUNOUT.md diff --git a/crates/skate-game/src/graph_host/mod.rs b/crates/skate-game/src/graph_host/mod.rs index 8ecb9673..6c65d71c 100644 --- a/crates/skate-game/src/graph_host/mod.rs +++ b/crates/skate-game/src/graph_host/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod motion_push_off; #[path = "motion_offboard/reset.rs"] pub(crate) mod motion_reset; mod motion_riding; +pub(crate) mod motion_runout; pub(crate) mod motion_riding_conditions; pub(crate) mod motion_shove; mod motion_sliding; diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs index 16f66a20..072fc7ad 100644 --- a/crates/skate-game/src/graph_host/motion.rs +++ b/crates/skate-game/src/graph_host/motion.rs @@ -44,6 +44,7 @@ pub struct MotionPhysical { pub foot_frame: Option, } enum Instance { + Runout(Option), Trick(i32), Stateless, IntentFilter(skate_core::animation::intent_filter::State), @@ -70,6 +71,7 @@ enum Instance { impl Instance { fn new(operation: &MotionOperation) -> Self { match operation { + MotionOperation::AddRunoutAttribs => Self::Runout(None), MotionOperation::Trick(_) => Self::Trick(0), MotionOperation::IntentFilter(_) => Self::IntentFilter(Default::default()), MotionOperation::Landing(_) => Self::Landing(Default::default()), @@ -126,6 +128,7 @@ pub struct MotionHost { pub riding_conditions: Option, pub condition_random: super::motion_riding_conditions::MotionRandom, pub native_physical: Option, + pub runout_physical: Option, pub prelanding_physical: Option, pub air_leg_physical: Option, pub gesture_physical: Option, @@ -218,6 +221,7 @@ impl MotionHost { riding_conditions: None, condition_random: super::motion_riding_conditions::MotionRandom::new(), native_physical: None, + runout_physical: None, prelanding_physical: None, air_leg_physical: None, gesture_physical: None, diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs index f6d3a93f..b0581999 100644 --- a/crates/skate-game/src/graph_host/motion_execute.rs +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -94,6 +94,21 @@ impl MotionHost { .get_mut(behavior) .ok_or("Unallocated MotionGraph behavior")?; match (operation, instance) { + (MotionOperation::AddRunoutAttribs, Instance::Runout(state)) => { + if phase == 0 { + *state = Some(super::super::motion_runout::capture( + self.runout_physical.ok_or("AddRunoutAttribs requires completed physical output")?, + self.animation.skater_animation_flags.ok_or("AddRunoutAttribs requires animation stance")? & 0x4000_0000 != 0, + )); + } else if phase == 1 { + let values = state.ok_or("AddRunoutAttribs updated before Begin")?; + for (name, value) in [("BipedStartAngle", values.angle_degrees), ("BipedSpeed", values.speed)] { + self.animation.set_attribute(SettableAttribute { + name: encode(name.as_bytes()), value, normalized: false, sequence_id: -1, + }); + } + } + } //Retail ctor82BA58E8 retains only diagnostic text/layout. All three //lifecycle slots in82309664 are82B61BB8 (blr), with no state writes. (MotionOperation::PrintText2D, _) => {} diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs index 874c7a7c..d46ccbfe 100644 --- a/crates/skate-game/src/graph_host/motion_nodes.rs +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -18,6 +18,7 @@ pub enum MotionOperation { Play(PlayAnimation), ///TU3 vtable82309664: Begin/Update/End all point to the empty82B61BB8. PrintText2D, + AddRunoutAttribs, AttachIntent { intent: String, attribute: AttributeName, @@ -113,6 +114,7 @@ impl OperationFactory for MotionFactory { )), "PlayAnimation" => Some(MotionOperation::Play(play(a))), "PrintText2D" => Some(MotionOperation::PrintText2D), + "AddRunoutAttribs" => Some(MotionOperation::AddRunoutAttribs), "ApplyingBodyTilt" => Some(MotionOperation::ApplyingBodyTilt), "SettingBodyTilt" => { Some(MotionOperation::SettingBodyTilt(key(a, "tilt_x", "tilt_x"))) diff --git a/crates/skate-game/src/graph_host/motion_runout.rs b/crates/skate-game/src/graph_host/motion_runout.rs new file mode 100644 index 00000000..31767b10 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_runout.rs @@ -0,0 +1,57 @@ +//! TU3 AddRunoutAttribs: factory82BC9BA8, vtable82320BDC, +//! Begin82BBA4A8, Update82BBA770, End82B61BB8. +use skate_core::{math::Vector3, physics::board_motion_output::length, + player::wipeout_state::orientation::projected_angle}; + +#[derive(Clone, Copy, Debug)] +pub struct Physical { + /// Skeleton0: effective animation root Z (Fill82BE1AE8 / GetEffectiveRoot). + pub forward: [f32; 4], + /// SystemReckoning16 and96, respectively. + pub velocity: [f32; 4], + pub up: [f32; 4], + /// OffBoard128, selected only when OffBoard331 is set. + pub trajectory_velocity: Option<[f32; 4]>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Parameters { + pub angle_degrees: f32, + pub speed: f32, +} + +pub fn capture(physical: Physical, mirrored: bool) -> Parameters { + let velocity = physical.trajectory_velocity.unwrap_or(physical.velocity); + let angle = projected_angle(physical.forward, velocity, physical.up); + let angle = if mirrored { -angle } else { angle }; + // Native wrap has a strict >0.5 boundary, preserving positive 180 degrees. + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let radians = (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) + * f32::from_bits(0x40c9_0fdb); + Parameters { + angle_degrees: radians * f32::from_bits(0x4265_2ee1), + speed: length(Vector3::new(velocity[0], velocity[1], velocity[2])), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn runout_direction_speed_stance_and_trajectory_selection() { + let mut p = Physical { forward: [0.,0.,1.,0.], velocity: [3.,4.,0.,0.], + up: [0.,1.,0.,0.], trajectory_velocity: None }; + let regular = capture(p, false); + let mirrored = capture(p, true); + assert!((regular.angle_degrees - 90.).abs() < 0.001); + assert!((mirrored.angle_degrees + 90.).abs() < 0.001); + assert!((regular.speed - 5.).abs() < 0.00001); + p.trajectory_velocity = Some([0.,0.,-2.,0.]); + assert!((capture(p, false).angle_degrees - 180.).abs() < 0.001); + assert!((capture(p, true).angle_degrees.abs() - 180.).abs() < 0.001); + assert!((capture(p, false).speed - 2.).abs() < 0.00001); + p.trajectory_velocity = Some([0.;4]); + assert_eq!(capture(p, false), Parameters { angle_degrees: 0., speed: 0. }); + } +} diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs index 65d6ea94..edf3746f 100644 --- a/crates/skate-game/src/physics/animation_phase.rs +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -175,6 +175,15 @@ pub(crate) fn advance( effective_z = effective_z.map(|v| -v); } let mut effective_x = skater.animated_skeleton.roots.animation_to_world[0]; + skater.animation.motion.runout_physical = Some(crate::graph_host::motion_runout::Physical { + forward: effective_z, + velocity: physical.reckoning.vector_16.map(f32::from_bits), + up: physical.reckoning.vector_96.map(f32::from_bits), + // OffBoard331 remains reset in the currently implemented riding/air states, + // matching the camera publication. Biped trajectory owners must publish + // OffBoard128 here when they enable that alternate native output. + trajectory_velocity: None, + }); if p.flags_2476 & 4 != 0 { effective_x = effective_x.map(|v| -v); } diff --git a/crates/skate-game/src/tests/wipeout_playback.rs b/crates/skate-game/src/tests/wipeout_playback.rs index 4a35694c..4799dd19 100644 --- a/crates/skate-game/src/tests/wipeout_playback.rs +++ b/crates/skate-game/src/tests/wipeout_playback.rs @@ -219,3 +219,37 @@ fn authored_checkpoint_reply_runs_teleport_state_and_restores_riding() { } assert!(saw_ready && restored_ticks > 60); } + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn runout_request_enters_stock_motion_graph_in_all_difficulties() { + 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(); + for difficulty in crate::difficulty::Difficulty::ALL { + let mut physics = GamePhysics::load_with_difficulty(root, None, difficulty).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, difficulty.key()).unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + for tick in 0..120 { + // Inject the completed runout request at the physical -> animation + // boundary; leave stock graph selection and all following ticks live. + input.sample_raw_for_test(XboxState { buttons: 0, triggers: [0;2], left: [0;2], 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!("Runout {difficulty:?} tick{tick}: {error}")); + assert_finite(&physics, &skater, tick); + } + // Exercise the logged MotionGraph branch, separately from the still + // unsupported BipedGround physical owner selected by its output. + skater.player_state.state_flags[78 - 52] = true; + animation_phase::advance(&physics, &mut skater, &controls, &graphs, + &physics.animation_profile).unwrap(); + } +} diff --git a/docs/RUNOUT.md b/docs/RUNOUT.md new file mode 100644 index 00000000..71149a72 --- /dev/null +++ b/docs/RUNOUT.md @@ -0,0 +1,41 @@ +# Runout crash investigation + +The Hardcore 360-flip reports at ticks 583 and 378 terminate at MotionGraph +behavior 86, `AddRunoutAttribs`. This operation was unsupported before the +difficulty-menu changes. Entering the stock runout graph now succeeds in Easy, +Normal and Hardcore. A full-frame regression then exposed the next missing +owner: `PhysicsGround -> BipedGround` has no production adapter yet. + +## Reference + +TU3 dump `default_82000000_011B0000.bin`, guest base 0x82000000, +SHA256 f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4. +Registration 0x82F89700 selects factory 0x82BC9BA8; vtable 0x82320BDC has +Begin 0x82BBA4A8, Update 0x82BBA770 and empty End 0x82B61BB8. +The initializer pair 0x82F85870/0x82F85888 identifies BipedStartAngle/BipedSpeed. +Addresses are guest addresses; subtract 0x82000000 for dump offsets. + +Observed: Begin reads Skeleton0 (effective animation root Z), +SystemReckoning16 (COM velocity), SystemReckoning96 (up), and mirrors through +ISkaterAnim slot28. OffBoard331 selects alternate velocity OffBoard128. +It uses 0x8286CD88 for the projected signed angle, wraps with the strict +fraction > 0.5 condition, converts radians to degrees using word 0x42652EE1, +and captures full three-dimensional speed. Update emits the retained values +with normalized=false and sequence=-1. End performs no writes. + +The game currently publishes the ordinary velocity branch; existing riding/air +states leave OffBoard331 at its native reset value. The alternate branch is +implemented and tested but requires the future BipedAir output owner. + +The host numerical routines follow recovered arithmetic; no hardware-matched +360-flip replay has been recorded. A test-injected runout request verifies the +stock animation branch in all three modes, not complete on-foot physics. + +## Remaining integration + +User explicitly selected implementing native on-foot/runout instead of an +error-recovery screen. BipedGround requires the contact toolkit (82D811C8 / +82D81610), ground job producer, controller placement/update, skeleton mode4, +board manager, state publications, and BipedAir/remount transitions. Existing +recovered modules are being compiled and connected; enabling modules alone is +not a working BipedGround implementation.