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);
+23 -1
View File
@@ -117,7 +117,7 @@ operands take precedence over unreliable decompiler output and community names.
| Handplant skeleton | State600 vtable `823273CC`; entry/update `82D4C350/82D4C3D8`; reckoning `82D8E1C0`; hand IK `82D633B0`, target clamp `82BD9728/82BD97D0`, upper-body volume suppression `82D91298`. |
| Shared skeleton | Anchored-foot path `82BDEC48`, COM path `82BDF090`, root positioning `82BDEEB8/82BE0E80`. Uses the existing completed animation, physical skeleton, board and four-limb IK owners. |
| Graph timing | `IsFootPlanting` `82BBD888`; preparation `82BBD7F0`; FootPlantAbsorb Update `82BBE4E0`; handplant phase/new-position `82BBDA98/82BBDB98`; antic Begin `82BBE5D0`; handplant scoring Update `82BBF758`. |
| Lifecycle | Footplant release `82D8B970` reads big-endian byte2480 bit0, hence full-word bit24. Common handplant publication `82DB7130..82DB7190`; shared plant wipeout check `82D8FDC0`. Teleport/reset paths clear active plant state. |
| Lifecycle | Footplant release `82D8B970` reads big-endian byte2480 bit0, hence full-word bit24. Common handplant publication `82DB7130..82DB7190`; FootPlant wipeout check `82D8FDC0`; HandPlant post `82D4C530` calls Air `82D90358(false)`. Teleport/reset paths clear active plant state. |
Settings are loaded from the installation, using the schema's cached layout:
@@ -155,3 +155,25 @@ reuse existing engine math; bit-exact Xenon/VMX execution is not claimed.
Handplant scoring updates the existing internal score packet, whose presentation
remains outside this change. Stock assets and private research/build files are
excluded from the commit.
## Handplant bail and delayed revert crash (8 September)
The supplied run entered HandPlant at tick745 and WipeoutGround at746, then
stopped at tick1318 on the missing PhysicsGround -> RevertGround adapter.
HandPlant incorrectly used FootPlant collision checks with unconditional trick
impact limits (stock XZ0.1/Y0.6). It now dispatches the native Air(false) checks,
including their contact, difficulty, cooldown and actual danger-state gates.
The original bail reason was not recorded, so the causal gameplay result still
requires a user retest. PLANT_BAIL now records reason indices and collision/pose
observations when either plant requests a bail.
RevertGround102 now owns Enter82D43488, Update82D43518, empty Exit, Ground
Post82D4C070 and Fill82D43B10. It loads the five fields from stock class
018E8A2E5028AB3F, captures approach velocity after the authored delay, applies
the directed rotation-speed curve, manual/anti-flip corrections and pumping,
and publishes active State66 for the existing selector's normal exit.
No gameplay execution was performed during this repair.
Validation for this repair: six core wipeout tests and two revert numerical
tests passed. Two pre-existing game test fixtures were updated for their current
map-menu and render-material API signatures so these tests could compile.