Correct handplant bail dispatch and implement RevertGround physics

This commit is contained in:
chazmm committed 2026-09-08 17:42:00 +01:00
1 parent ab8f889603
commit 0ddc174a51
15 files changed
+386 -5

No files matched your search

@@ -293,7 +293,7 @@ impl BoardGroundState {
/// Complete8296EBB0, including its small-vector gate and ONE reciprocal-square-
/// root refinement per vector. Native82C07D20 compares this angle with pi/4.
pub(crate) fn angle_between(a: Vector3, b: Vector3) -> f32 {
pub fn angle_between(a: Vector3, b: Vector3) -> f32 {
let a_squared = dot(a, a);
let b_squared = dot(b, b);
let epsilon = f32::from_bits(0x38D1_B717);
@@ -127,7 +127,7 @@ pub fn check_animation(state: &mut Requests, s: &Settings, mode: &Mode, f: &Fram
}
}
///82D8FDC0, shared FootPlant/HandPlant postphysics collision checks.
///82D8FDC0, FootPlant postphysics checks. HandPlant calls check_air(false).
pub fn check_plant(state: &mut Requests, s: &Settings, f: &Frame) {
state.mode = 3;
if f.maximum_pose_error > s.ground.max_squash {
@@ -1,6 +1,26 @@
use super::*;
use crate::physics::skeleton_animation_record::IDENTITY;
use crate::point_graph::PointGraph;
#[test]
fn handplant_air_check_preserves_contact_threshold_and_real_impact_bails() {
let mut f=input();
f.category=600;
f.closing_velocity=[0.2,0.0,0.0,0.0];
let mut s=settings();
s.air.xz_trick=0.1;
s.air.y_trick=0.6;
let mut foot=Requests::new();
check_plant(&mut foot,&s,&f);
assert!(foot.reasons[2]);
//HandPlant82D4C530 calls Air(false); a coping contact is not by itself
//the sideways-board danger branch that enables the tiny trick limits.
let mut hand=Requests::new();
check_air(&mut hand,&s,&mode(),&f,false);
assert_eq!(hand.count,0);
f.closing_velocity=[11.0,0.0,0.0,0.0];
check_air(&mut hand,&s,&mode(),&f,false);
assert!(hand.reasons[2]);
}
fn input() -> Frame {
Frame {
flags_2468: 0,
+1
View File
@@ -461,6 +461,7 @@ mod tests {
.insert_resource(images)
.insert_resource(Menu {
open: false, selected: 0, settings: GraphicsSettings::default(),
maps: Vec::new(), selected_map: 0,
difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(),
})
.add_systems(Update, apply);
+1
View File
@@ -39,6 +39,7 @@ mod grind;
mod ground_phase;
mod ground_animation;
mod slide_state;
mod revert_state;
mod ground_exit;
mod ground_runtime;
mod input_phase;
+1
View File
@@ -112,6 +112,7 @@ pub(super) fn advance(
}
player_state::pre_state(physics, skater)?;
match skater.player_state.current() {
skate_core::player::state::PhysicalStateId::RevertGround => super::revert_state::update(physics,skater)?,
skate_core::player::state::PhysicalStateId::HandPlant => super::handplant::update(physics,skater)?,
skate_core::player::state::PhysicalStateId::FootPlant => super::footplant::ground::update(physics, skater)?,
skate_core::player::state::PhysicalStateId::Boneless => super::boneless::update(physics, skater)?,
@@ -27,6 +27,46 @@ pub(crate) struct GroundPumpingMode {
pub unintentional_scalar: f32,
}
impl GroundPumping {
///Revert82D435FC..36A0 uses the processed timestep and flag2472 bit16.
pub fn update_revert(
&self,
state: &mut PumpingState,
toolkit: &BoardToolkit,
riding: &RidingOutputs,
skeleton: &AnimatedSkeleton,
p: &skate_core::player::input_phase::ProcessedPhysicsInput,
balance: f32,
) -> Result<(), String> {
let angle = if balance != 0.0 {
0.0
} else {
(skate_core::physics::board_ground::angle_between(
xyz(toolkit.deck[2]),
riding.reckoning.up,
)
.abs()
- std::f32::consts::FRAC_PI_2)
.abs()
};
let sample = PumpingSample {
position: toolkit.deck[3],
normal: v(riding.reckoning.ground_normal),
com_to_deck_world: skeleton.record.com_to_deck_world,
deck_angle: angle,
intentional_pumping: ((p.flags_2472 >> 16) & 1) as u8,
};
match controller::update(
state,
&self.settings,
self.mode(p.state_variant_index_2528)?.controller,
sample,
p.timestep_2604,
&mut NativePumpingGeometry,
) {
Ok(()) => Ok(()),
Err(never) => match never {},
}
}
pub fn load(data: &Collections) -> Result<Self, String> {
let f = |field| data.float("physics_pumping", "default", field);
let c = |field| super::settings::curve8(data, "physics_pumping", "default", field);
@@ -18,6 +18,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
| PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant
| PhysicalStateId::GroundAnimation
| PhysicalStateId::SlideGround
| PhysicalStateId::RevertGround
| PhysicalStateId::WipeoutGround
| PhysicalStateId::Teleporting
| PhysicalStateId::BipedAir
@@ -19,6 +19,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
| PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant
| PhysicalStateId::GroundAnimation
| PhysicalStateId::SlideGround
| PhysicalStateId::RevertGround
| PhysicalStateId::WipeoutGround
| PhysicalStateId::Teleporting
| PhysicalStateId::BipedAir
@@ -58,6 +59,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
// State63 is set only by LandingOnDeckManager::Fill82D4E0E8..E0F4
// when byte246 && time240<.02. Ordinary Ground never invokes that Fill,
// so it retains the template zero here; State65 carries Ground requests.
set(66, state == PhysicalStateId::RevertGround && skater.revert_state.active);
set(65, skater.wipeout.requests_wipeout(p));
set(78, skater.wipeout.requests_runout(p));
set(71, player.flags_1296 & (1 << 24) != 0);
@@ -128,6 +130,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
physical.state = CurrentStateFields {
category_12: state.category(),
state_16: state as u32,
flag_66: u8::from(state == PhysicalStateId::RevertGround && skater.revert_state.active),
flag_74: u8::from(state==PhysicalStateId::HandPlant && skater.handplant.continuation),
//ProcessOutput82DB7104..7128: selector57 requests State69 here.
//The next input/selection enters702; never reset inside selection.
@@ -21,6 +21,7 @@ impl PhysicalStateCalls for Calls {
| PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant
| PhysicalStateId::GroundAnimation
| PhysicalStateId::SlideGround
| PhysicalStateId::RevertGround
| PhysicalStateId::WipeoutGround
| PhysicalStateId::Teleporting
| PhysicalStateId::BipedAir
@@ -38,6 +39,7 @@ impl PhysicalStateCalls for Calls {
| PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant
| PhysicalStateId::GroundAnimation
| PhysicalStateId::SlideGround
| PhysicalStateId::RevertGround
| PhysicalStateId::WipeoutGround
| PhysicalStateId::Teleporting
| PhysicalStateId::BipedAir
@@ -67,6 +69,7 @@ pub(super) fn set(
| PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant
| PhysicalStateId::GroundAnimation
| PhysicalStateId::SlideGround
| PhysicalStateId::RevertGround
| PhysicalStateId::WipeoutGround
| PhysicalStateId::Teleporting
| PhysicalStateId::BipedAir
@@ -80,6 +83,7 @@ pub(super) fn set(
| PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant
| PhysicalStateId::GroundAnimation
| PhysicalStateId::SlideGround
| PhysicalStateId::RevertGround
| PhysicalStateId::WipeoutGround
| PhysicalStateId::Teleporting
| PhysicalStateId::BipedAir
@@ -161,6 +165,7 @@ pub(super) fn set(
//Native82DB8540 publishes Processed state/history BEFORE old Exit. The
//same retained objects then receive Exit followed by the new Enter.
match current {
PhysicalStateId::RevertGround => {}, //Exit82B61BB8
PhysicalStateId::HandPlant => skater.handplant.reset(),
PhysicalStateId::FootPlant => skater.footplant.reset(), //Exit82D4C5A8
PhysicalStateId::Boneless => {}, //empty82D4C9B4
@@ -179,6 +184,7 @@ pub(super) fn set(
_ => unreachable!("state support checked before publication"),
}
match requested {
PhysicalStateId::RevertGround => super::super::revert_state::enter(physics, skater),
PhysicalStateId::HandPlant => super::super::handplant::enter(physics, skater),
PhysicalStateId::FootPlant => super::super::footplant::ground::enter(physics, skater),
PhysicalStateId::Boneless => super::super::boneless::enter(physics, skater),
@@ -0,0 +1,266 @@
//! RevertGround102, TU3 vtable82327330: Enter82D43488, Update82D43518,
//! empty Exit, Ground Post82D4C070, Fill82D43B10. Stock layout018E8A2E5028AB3F.
use super::{GamePhysics, SkaterRuntime, ground_runtime::GroundInputObservations};
use skate_core::{
math::Vector3,
physics::{force_queue::QueuedPointForce, manual::controller},
point_graph::PointGraph,
riding::{anti_flip, collision_response::signed_angle, pumping},
};
use skate_data::collections::Collections;
type V = [f32; 4];
pub(crate) struct RevertState {
speed: PointGraph<8>,
tolerance: PointGraph<4>,
gain: f32,
delay: f32,
duration: f32,
direction: f32,
travel_sign: f32,
velocity: V,
elapsed: f32,
captured: bool,
pub active: bool,
}
impl RevertState {
pub fn load(d: &Collections) -> Result<Self, String> {
let class = "Hash_018E8A2E5028AB3F";
let a = d
.words::<20>(class, "default", "Hash_DAA990E803A782A1")?
.map(f32::from_bits);
let b = d
.words::<12>(class, "default", "Hash_3387562E9BEA0AF6")?
.map(f32::from_bits);
Ok(Self {
speed: PointGraph {
x: a[4..12].try_into().unwrap(),
y: a[12..20].try_into().unwrap(),
},
tolerance: PointGraph {
x: b[4..8].try_into().unwrap(),
y: b[8..12].try_into().unwrap(),
},
gain: d.float(class, "default", "Hash_0E5B939FD4ECDBAA")?,
delay: d.float(class, "default", "Hash_6BF6544475D2A5AD")?,
duration: d.float(class, "default", "Hash_035D9805EB9E2EE2")?,
direction: 0.0,
travel_sign: 1.0,
velocity: [0.0; 4],
elapsed: 0.0,
captured: false,
active: false,
})
}
///82D43800: directed remaining angle, stock speed curve and rate damping.
fn correction(&mut self, forward: V, normal: V, angular: V) -> V {
if !self.captured {
return [0.0; 4];
}
let desired = forward.map(|v| v * self.travel_sign);
//Preserve the two native self-scaled projections82D438B4..D0.
let project = |v: V| {
let d = dot(v, normal);
v.map(|x| x - x * d)
};
let from = project(self.velocity);
let to = project(desired);
if dot(from, from) * dot(to, to) <= f32::from_bits(0x37800000) {
return [0.0; 4];
}
let normalize = |v: V| {
let length = dot(v, v).sqrt();
v.map(|x| x / length)
};
let mut angle = signed_angle(xyz(normalize(from)), xyz(normalize(to)), xyz(normal));
//8258DB98 folds the helper's[0,2pi] output before the direction test.
if angle >= std::f32::consts::PI {
angle -= std::f32::consts::TAU;
}
if angle * self.direction > 0.0 {
angle -= self.direction * std::f32::consts::TAU;
}
let target = self.speed.evaluate(angle.abs()) * self.direction;
if angle.abs() < self.tolerance.evaluate(normal[1]) * f32::from_bits(0x3c8efa35)
|| angle.abs() > 4.5
{
self.active = false;
}
let current = dot(angular, normal);
let correction = if current * target > 0.0 && current.abs() > target.abs() {
0.0
} else {
(target - current) * self.gain
};
normal.map(|v| v * correction)
}
}
pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> {
skater.ground_lifecycle.skeleton_elapsed_16505 = true;
physics
.board
.hook_mut()
.drive
.disable_animation(&mut skater.ground_lifecycle.board_animated_290);
let r = &mut skater.revert_state;
r.direction = skater.animation_input.extra.revert_direction;
r.velocity = [0.0; 4];
r.elapsed = 0.0;
r.captured = false;
r.active = true;
skater.wipeout.state.enter_ground();
Ok(())
}
pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> {
let p = &skater.player_input.processed;
let t = skater
.player_input
.toolkit
.as_ref()
.ok_or("Revert requires current BoardToolkit")?;
let r = &mut skater.revert_state;
if !r.captured && r.elapsed >= r.delay {
r.captured = true;
r.velocity = p.vectors_400_416[0].map(f32::from_bits);
r.travel_sign = if dot(r.velocity, t.deck[2]) > 0.0 {
-1.0
} else {
1.0
};
}
physics.riding.update_slide_reckoning(
p,
t,
skater.animation_input.extra.physical_body_spin,
skater.animation_input.fields.balance,
);
super::input_phase::update_ground(physics, skater)?;
let p = &skater.player_input.processed;
let t = skater
.player_input
.toolkit
.as_ref()
.ok_or("Revert requires current BoardToolkit")?;
skater.ground.pumping_settings.update_revert(
&mut skater.ground.pumping,
t,
&physics.riding,
&skater.animated_skeleton,
p,
skater.animation_input.fields.balance,
)?;
let input = skater.ground_settings.input(
t,
p,
&skater.animation_input,
&skater.ground.pumping,
skater
.ground
.pumping_settings
.mode(p.state_variant_index_2528)?
.unintentional_scalar,
&physics.riding,
&skater.animated_skeleton,
physics.settings.step.base_truck_transforms,
GroundInputObservations {
manual_drag_2724: skater.ground_lifecycle.manual_drag_2724,
trajectory_state_bits: p.external_physics_1616.flags,
edge_flags: 0,
edge_point: [0.0; 4],
},
);
let settings = skater.ground_settings.board();
let correction = skater.revert_state.correction(
t.deck[2],
p.vectors_464_480_496_512_528[0].map(f32::from_bits),
p.vectors_720_784_800_816_832_864[0].map(f32::from_bits),
);
//82C040F0(0): zero linear drag on every board body.
for body in physics.board.bodies_mut() {
body.inertia.linear_drag = 0.0;
}
let anti_flip = anti_flip::calculate(settings.anti_flip, &input.anti_flip);
let pump = pumping::calculate(&input.pump_force);
let manual = controller::calculate(
&mut skater.ground.manual,
settings.manual,
settings.manual_mode,
&input.manual,
&mut skater.ground_runtime,
)
.map_err(|e| format!("Revert manual: {e:?}"))?;
for value in [correction, manual.angular_displacement, anti_flip] {
skater
.ground_runtime
.apply_angular_displacement(&mut physics.board, value);
}
physics.board.forces_mut().append(QueuedPointForce {
tag: 8,
force_world: Vector3::new(pump[0], pump[1], pump[2]),
point_body: Vector3::new(pump[4], pump[5], pump[6]),
});
skater.revert_state.elapsed += p.timestep_2604;
if skater.revert_state.elapsed > skater.revert_state.duration {
skater.revert_state.active = false;
}
Ok(())
}
fn dot(a: V, b: V) -> f32 {
a[2].mul_add(b[2], a[1].mul_add(b[1], a[0] * b[0]))
}
fn xyz(v: V) -> Vector3 {
Vector3::new(v[0], v[1], v[2])
}
#[cfg(test)]
mod tests {
use super::*;
fn state(direction: f32) -> RevertState {
RevertState {
speed: PointGraph {
x: [0.0, 0.1, 0.2, 0.3, 1.0, 2.0, 3.0, 7.0],
y: [5.0; 8],
},
tolerance: PointGraph {
x: [0.0, 0.2, 0.6, 1.0],
y: [8.0; 4],
},
gain: 0.06,
delay: 0.0,
duration: 1.0,
direction,
travel_sign: -1.0,
velocity: [0.0, 0.0, 6.0, 0.0],
elapsed: 0.0,
captured: true,
active: true,
}
}
#[test]
fn revert_steers_both_directions_and_does_not_brake_faster_matching_spin() {
for direction in [-1.0, 1.0] {
let mut r = state(direction);
let correction = r.correction([0.0, 0.0, 1.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0; 4]);
assert!((correction[1] - 0.3 * direction).abs() < 0.00001);
assert!(r.active);
assert_eq!(
r.correction(
[0.0, 0.0, 1.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 6.0 * direction, 0.0, 0.0]
),
[0.0; 4]
);
}
}
#[test]
fn revert_completes_when_board_faces_captured_reverse_heading() {
let mut r = state(1.0);
r.correction([0.0, 0.0, -1.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0; 4]);
assert!(!r.active);
r.velocity = [0.0; 4];
assert_eq!(
r.correction([0.0, 0.0, 1.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0; 4]),
[0.0; 4]
);
}
}
+2
View File
@@ -42,6 +42,7 @@ pub(crate) struct SkaterRuntime {
pub known_air: super::known_air::KnownAir,
pub ground_animation: super::ground_animation::GroundAnimationRuntime,
pub ground_animation_settings: super::ground_animation::GroundAnimationSettings,
pub revert_state: super::revert_state::RevertState,
pub slide_state: super::slide_state::SlideState,
pub trajectory: super::air_trajectory::AirTrajectoryRuntime,
pub footplant: super::footplant::Footplant,
@@ -205,6 +206,7 @@ impl SkaterRuntime {
known_air: super::known_air::KnownAir::load(&data)?,
ground_animation: Default::default(),
ground_animation_settings: super::ground_animation::GroundAnimationSettings::load(&data)?,
revert_state: super::revert_state::RevertState::load(&data)?,
slide_state: super::slide_state::SlideState::load(&data)?,
trajectory: super::air_trajectory::AirTrajectoryRuntime::load(&data)?,
footplant: super::footplant::Footplant::load(&data)?,
+18 -2
View File
@@ -95,7 +95,23 @@ pub(super) fn check_after_physics(
};
match skater.player_state.current() {
PhysicalStateId::FootPlant | PhysicalStateId::HandPlant => {
wipeout::check_plant(&mut skater.wipeout.state, &skater.wipeout.settings, &observations.frame()?);
let frame = observations.frame()?;
if skater.player_state.current() == PhysicalStateId::HandPlant {
//HandPlant vtable823273CC slot40 ->82D4C530 ->82D90358(false).
//FootPlant's82D4C6D8 alone uses82D8FDC0. Its low trick-impact
//thresholds must not replace HandPlant's normal air checks.
skater.wipeout.check_air(&observations, false)?;
} else {
wipeout::check_plant(&mut skater.wipeout.state, &skater.wipeout.settings, &frame);
}
if skater.wipeout.state.count != 0 {
let reasons: Vec<_> = skater.wipeout.state.reasons.iter().enumerate()
.filter_map(|(i, &active)| active.then_some(i)).collect();
bevy::log::info!("PLANT_BAIL tick={} state={:?} reasons={reasons:?} pose_error={:?} max_pose_error={} regions={:?} closing={:?} board_contact={} world_to_animation={:?}",
physics.ticks, skater.player_state.current(), frame.pose_error,
frame.maximum_pose_error, frame.regions_force, frame.closing_velocity,
frame.board_contact, frame.world_to_animation);
}
if skater.player_state.current() == PhysicalStateId::FootPlant {
super::footplant::ground::post_physics(skater);
}
@@ -127,7 +143,7 @@ pub(super) fn check_after_physics(
super::offboard::air_diagnostics::record(skater);
Ok(())
}
PhysicalStateId::PhysicsGround | PhysicalStateId::SlideGround => {
PhysicalStateId::PhysicsGround | PhysicalStateId::SlideGround | PhysicalStateId::RevertGround => {
skater.wipeout.check_ground(&observations)
}
PhysicalStateId::GroundAnimation => {
+2
View File
@@ -853,12 +853,14 @@ mod tests {
let mut queue = bevy::ecs::world::CommandQueue::default();
let mut meshes = Assets::<Mesh>::default();
let mut materials = Assets::<StandardMaterial>::default();
let mut retail_materials = Assets::<crate::retail_render::RetailWorldMaterial>::default();
let mut images = Assets::<Image>::default();
spawn(
&map,
&mut Commands::new(&mut queue, &world),
&mut meshes,
&mut materials,
&mut retail_materials,
&mut images,
);
queue.apply(&mut world);