diff --git a/.gitignore b/.gitignore index 86939c89..8f5208dd 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ __pycache__/ /tools/launch_customiser_test.bat /Build-SessionMarker.ps1 /SETUP-PROVENANCE.json +/LaunchPlants.bat diff --git a/crates/skate-core/src/air/reckoning/mod.rs b/crates/skate-core/src/air/reckoning/mod.rs index e5d138fc..83ed5b00 100644 --- a/crates/skate-core/src/air/reckoning/mod.rs +++ b/crates/skate-core/src/air/reckoning/mod.rs @@ -125,5 +125,25 @@ fn vector(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } +///Handplant Reckoning82D8E1C0. Uses the ordinary persistent normal filters. +pub fn update_plant(orientation:&mut GroundOrientation,frames:&mut ReckoningFrames, + body_spin:&mut BodySpinState,state:&mut AirState,settings:&Settings, + up:[f32;4],heading:[f32;4],reverse:bool) { + frames.heading=heading; + body_spin::update(body_spin,&settings.body_spin,0.0,0.0,true,0); + let previous=lanes(orientation.up); + orientation.dynamic_up=orientation.up; + frames.target_lean_angle=0.0;state.secondary_lean_angle=0.0; + let normal=normalize(orientation.ground_filter.update(settings.ground_normal_smoothing,up)); + orientation.ground_normal=vector(normal); + let up=normalize_safe(limit_angle(normal,previous,1.0),previous); + orientation.up_velocity=vector(std::array::from_fn(|i|up[i]-previous[i])); + orientation.up=vector(up);orientation.target=orientation.up; + orientation.slow_filter.filter(up);orientation.fast_filter.filter(up); + orientation.slow_filter.publish_current(up);orientation.fast_filter.publish_current(up); + frames.calculate_transform(up,normal); + frames.calculate_tilt(reverse,&settings.tilt_vs_rotation,&settings.tilt_vs_slope); +} + #[cfg(test)] mod tests; diff --git a/crates/skate-core/src/input/trick_intentions.rs b/crates/skate-core/src/input/trick_intentions.rs index 8027576d..5894a734 100644 --- a/crates/skate-core/src/input/trick_intentions.rs +++ b/crates/skate-core/src/input/trick_intentions.rs @@ -81,6 +81,24 @@ pub fn produce(controller: &DerivedControllerInput) -> Vec { if right_y != 0.0 { emit("TweakY", right_y); } + //Fill8259A554/8259AF60: current packed bit28 (RB), without an actor gate. + //The stock ground graph attaches GrabWorld; physics selects valid coping. + if current_flags & (1 << 28) != 0 { + emit("GrabWorld", 1.0); + } + //Fill calls8259BB18 with the CURRENT RawControllerInput (words7..13). + //Descriptor constructors82F84F28..82F84F98 establish these five names. + if right_x != 0.0 { + emit("HandPlantTweakX", right_x); + } + if right_y != 0.0 { + emit("HandPlantTweakY", right_y); + } + for (bit, name) in [(20, "HandPlantDismount"), (21, "HandPlantOneFootRight"), (23, "HandPlantOneFootLeft")] { + if current_flags & (1 << bit) != 0 { + emit(name, 1.0); + } + } output } diff --git a/crates/skate-core/src/physics/board_ground.rs b/crates/skate-core/src/physics/board_ground.rs index 78ce0ad0..48745f82 100644 --- a/crates/skate-core/src/physics/board_ground.rs +++ b/crates/skate-core/src/physics/board_ground.rs @@ -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); diff --git a/crates/skate-core/src/physics/skeleton_air_frames.rs b/crates/skate-core/src/physics/skeleton_air_frames.rs index a422dac1..823e54a9 100644 --- a/crates/skate-core/src/physics/skeleton_air_frames.rs +++ b/crates/skate-core/src/physics/skeleton_air_frames.rs @@ -103,3 +103,21 @@ pub fn finish_known_air( roots.predicted_board_position = effective_board[3]; roots.supplied_prediction = Some(effective_board[3]); } + +///82BDEEB8/82BE0E80: anchor an animation bone or COM in world space. +///Plants retain heading_alignment and do not consume board prediction. +pub fn update_plant_roots( + roots: &mut SkeletonRootFrames, + reckoning: &Transform, + world_anchor: [f32; 4], + animation_anchor: [f32; 4], +) { + let mut world = compose_affine(reckoning, &roots.heading_alignment); + world[3] = std::array::from_fn(|i| { + let x = world[0][i] * animation_anchor[0]; + let y = world[1][i].mul_add(animation_anchor[1], x); + world_anchor[i] - world[2][i].mul_add(animation_anchor[2], y) + }); + roots.animation_to_world = orthonormalize(world); + roots.world_to_animation = inverse_rigid(&roots.animation_to_world); +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_mode.rs b/crates/skate-core/src/physics/skeleton_body/collision_mode.rs index 93ae440c..ed6e64fb 100644 --- a/crates/skate-core/src/physics/skeleton_body/collision_mode.rs +++ b/crates/skate-core/src/physics/skeleton_body/collision_mode.rs @@ -39,6 +39,17 @@ pub struct SkeletonCollisionMode { } impl SkeletonCollisionMode { + ///82D91298: Body volume pointers start at3092, counters at8128. + /// The stores at3120/3104/3124/3108/3096 therefore select parts7/3/8/4/1: + /// both hands, both forearms and the head, not the next bone in each chain. + pub fn disable_handplant_contacts(&mut self, frames: u32) { + self.pending_reenable = true; + for part in [7, 3, 8, 4, 1] { + self.disable_count[part] = frames; + self.parts[part].enabled = false; + } + } + ///82BE7078, used by the real teleport path82BE3508. Collision volume ///enabled flags and materials survive this reset. pub fn reset_body_state(&mut self, feedback: &mut SkeletonCollisionFeedback) { diff --git a/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs b/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs index 6631a1d7..e8740d1b 100644 --- a/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs +++ b/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs @@ -1,6 +1,33 @@ use super::*; use crate::physics::{contact::RetailContactMaterial, skeleton_animation_record::IDENTITY}; +#[test] +fn handplant_contact_mask_survives_updates_and_restores_on_exit() { + let mut collision = SkeletonCollisionMode::new_normal(settings().body, false); + for _ in 0..3 { + collision.disable_handplant_contacts(2); + // A driven-state refresh must not restore these volumes mid-plant. + collision.select_driven(5).unwrap(); + for part in [1, 3, 4, 7, 8] { + assert!(!collision.parts[part].enabled, "plant contact part {part}"); + } + for part in [2, 5, 6, 9, 10, 15, 19] { + assert!(collision.parts[part].enabled, "unaffected part {part}"); + } + collision.finish_contact_frame(); + assert!(collision.pending_reenable); + assert_eq!(collision.disable_count[3], 1); + assert!(!collision.parts[3].enabled); + } + // No refresh from the plant on the next tick: restore normal contact. + collision.finish_contact_frame(); + assert!(!collision.pending_reenable); + for part in [1, 3, 4, 7, 8] { + assert!(collision.parts[part].enabled); + assert_eq!(collision.disable_count[part], 0); + } +} + fn settings() -> SkeletonFeedbackSettings { SkeletonFeedbackSettings { body: SkeletonCollisionSettings { diff --git a/crates/skate-core/src/player/input_phase/air_output.rs b/crates/skate-core/src/player/input_phase/air_output.rs index 0dc33db3..89775ea8 100644 --- a/crates/skate-core/src/player/input_phase/air_output.rs +++ b/crates/skate-core/src/player/input_phase/air_output.rs @@ -24,10 +24,19 @@ pub struct AirOutputFields { pub flag_444: u8, /// Air324 high bits used by IsHandPlanting82BA55A8. pub handplant_flags_324: u32, + pub handplant_position_304: RawVector, + pub handplant_time_320: f32, pub vector_160: RawVector, pub scalar_184: f32, pub landing_normal_144: RawVector, pub jump_height_200: f32, + /// FootPlantManager::Fill82D71040: predicted contact and ground-plant duration. + pub footplant_contact_time_208: f32, + pub footplant_duration_212: f32, + pub footplant_surface_height_216: f32, + pub footplant_surface_224: u32, + pub footplant_left_449: u8, + pub footplant_right_450: u8, pub reached_apex_436: u8, pub flag_441: u8, pub flag_446: u8, diff --git a/crates/skate-core/src/player/selector/offboard.rs b/crates/skate-core/src/player/selector/offboard.rs index 363e4767..e2acb71a 100644 --- a/crates/skate-core/src/player/selector/offboard.rs +++ b/crates/skate-core/src/player/selector/offboard.rs @@ -85,7 +85,9 @@ impl StateSelector { PhysicalStateId::FootPlant => { if facts.wipeout { PhysicalStateId::WipeoutGround - } else if !p.has_2480(1) { + //82D8B970 loads the first byte of the big-endian word, + //then tests its low bit: full-word bit24 is Air448. + } else if !p.has_2480(0x0100_0000) { p.air_variant_from_2468() } else { current diff --git a/crates/skate-core/src/player/wipeout/ground.rs b/crates/skate-core/src/player/wipeout/ground.rs index c762b1ed..e4c41400 100644 --- a/crates/skate-core/src/player/wipeout/ground.rs +++ b/crates/skate-core/src/player/wipeout/ground.rs @@ -126,3 +126,16 @@ pub fn check_animation(state: &mut Requests, s: &Settings, mode: &Mode, f: &Fram state.request(20, 0.0); } } + +///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 { + state.request(18, 0.0); + } else if super::common::length(f.pose_error) > s.air.max_displacement { + state.request(1, 0.0); + } else if super::common::force(f, s.air.max_contact, s.air.max_arm_contact) { + state.request(0, 0.0); + } + common::closing(state, f, s.air.xz_trick, s.air.y_trick); +} diff --git a/crates/skate-core/src/player/wipeout/mod.rs b/crates/skate-core/src/player/wipeout/mod.rs index 34718157..1a26f369 100644 --- a/crates/skate-core/src/player/wipeout/mod.rs +++ b/crates/skate-core/src/player/wipeout/mod.rs @@ -7,7 +7,7 @@ mod ground; mod requests; pub use air::check as check_air; pub use data::{AirSettings, Frame, GroundSettings, Mode, RequestInput, Settings, V}; -pub use ground::{check as check_ground, check_animation as check_ground_animation}; +pub use ground::{check as check_ground, check_animation as check_ground_animation, check_plant}; pub use requests::Requests; pub use common::force as regional_force; diff --git a/crates/skate-core/src/player/wipeout/tests.rs b/crates/skate-core/src/player/wipeout/tests.rs index 5c5d4041..de440445 100644 --- a/crates/skate-core/src/player/wipeout/tests.rs +++ b/crates/skate-core/src/player/wipeout/tests.rs @@ -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, diff --git a/crates/skate-game/src/graph_host/action.rs b/crates/skate-game/src/graph_host/action.rs index ceb40416..95aaa40c 100644 --- a/crates/skate-game/src/graph_host/action.rs +++ b/crates/skate-game/src/graph_host/action.rs @@ -34,6 +34,7 @@ pub struct ActionHost { pub gameplay_conditions: Option, pub animation_attributes: Vec, pub is_tricking: Option, + pub dropping_in: Option, pub stance: Option<(bool, bool)>, tick: u64, pub errors: GraphDiagnostics, @@ -138,6 +139,7 @@ impl ActionHost { gameplay_conditions: None, animation_attributes: Vec::new(), is_tricking: None, + dropping_in: None, state_parents: Vec::new(), remap, stance: None, diff --git a/crates/skate-game/src/graph_host/action_conditions.rs b/crates/skate-game/src/graph_host/action_conditions.rs index 73d85cb9..8795056a 100644 --- a/crates/skate-game/src/graph_host/action_conditions.rs +++ b/crates/skate-game/src/graph_host/action_conditions.rs @@ -20,6 +20,7 @@ pub enum ActionCondition { }, Physical(GameplayCondition), Tricking, + DroppingIn, InLocomotion, DisableDismount, TimeToLand(NumericCondition), @@ -39,8 +40,9 @@ impl ActionCondition { target: None, numeric: super::condition_nodes::numeric(a), }, - "IsGrabbingObject" | "IsHandPlanting" => Self::Physical(GameplayCondition::parse(a)?), + "IsGrabbingObject" | "IsHandPlanting" | "IsFootPlanting" => Self::Physical(GameplayCondition::parse(a)?), "IsTricking" => Self::Tricking, + "IsDroppingIn" => Self::DroppingIn, "IsInLocomotion" => Self::InLocomotion, "DisableDismount" => Self::DisableDismount, "TimeToLand" => Self::TimeToLand(super::condition_nodes::numeric(a)), @@ -49,6 +51,8 @@ impl ActionCondition { } pub fn evaluate(&self, host: &ActionHost, frame: &Frame) -> Result { Ok(match self { + Self::DroppingIn => host.dropping_in + .ok_or("IsDroppingIn requires physical grind publication")?, // Same native physical leaf used by MotionGraph::TimeToLand. Self::TimeToLand(numeric) => numeric.matches( host.gameplay_conditions diff --git a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs index 410162e1..d85a2272 100644 --- a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs +++ b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs @@ -19,6 +19,12 @@ pub struct GameplayConditions { pub can_land_on_board: bool, pub hippy_hurdling: bool, pub handplant_flags: u32, + pub handplant_time: f32, + /// anim_handplant/default layout0/4/8: out, into, antic. + pub handplant_thresholds: [f32; 3], + pub footplant_active: bool, + pub footplant_duration: f32, + pub footplant_contact_time: f32, pub time_to_skitch: f32, pub skitch_transition_time: f32, /// TimeToLand82BA7250 reads Air184 when Air437 is valid. @@ -50,6 +56,10 @@ pub enum GameplayCondition { DarkCatchRequested, OkToDoTrickOnStairs, HandPlanting { state: u8, direction: u8 }, + FootPlanting, + PrepareFootplant, + NewHandplantPosition, + PlayHandplant { phase: usize }, EnteringSkitch, Skitching, } @@ -75,6 +85,10 @@ impl GameplayCondition { | "IsDarkCatchRequested" | "OkToDoTrickOnStairs" | "IsHandPlanting" + | "IsFootPlanting" + | "ShouldPrepareOneFootAirForFootplant" + | "HasNewHandPlantPos" + | "ShouldPlayHandPlantAnim" | "IsEnteringSkitch" | "IsSkitching" ) @@ -102,6 +116,13 @@ impl GameplayCondition { "OkToDoTrickOnStairs" => Self::OkToDoTrickOnStairs, "IsEnteringSkitch" => Self::EnteringSkitch, "IsSkitching" => Self::Skitching, + "IsFootPlanting" => Self::FootPlanting, + "ShouldPrepareOneFootAirForFootplant" => Self::PrepareFootplant, + "HasNewHandPlantPos" => Self::NewHandplantPosition, + "ShouldPlayHandPlantAnim" => Self::PlayHandplant { phase: match a.text("anim") { + Some("antic") => 2, Some("into") => 1, Some("out") => 0, + value => return Err(format!("Unauthored handplant animation phase {value:?}")), + } }, "IsHandPlanting" => Self::HandPlanting { //82BA54A0 compares these authored strings in this order. state: match a.text("state").unwrap_or("") { @@ -155,6 +176,17 @@ impl GameplayCondition { _ => false, } } + // Factory82BCB308 -> VT82321070 slot48=82BBD888. + // Both graphs read Air448 and Air212, including the entry frame. + Self::FootPlanting => p.footplant_active && p.footplant_duration >= 0.0, + //82BBD7F0, literals820641A8 and8209975C. + Self::PrepareFootplant => p.footplant_contact_time >= 0.1 && p.footplant_contact_time <= 0.5, + Self::NewHandplantPosition => p.handplant_flags & 0x4000_0000 != 0, //82BBDB98 + Self::PlayHandplant { phase } => { + //82BBDA98 subtracts one simulation tick before the comparison. + let threshold = p.handplant_thresholds[*phase]; + p.handplant_time - f32::from_bits(0x3c888889) < if *phase == 0 { -threshold } else { threshold } + } Self::DroppingBoard | Self::Dark | Self::UnderflipRequested diff --git a/crates/skate-game/src/graph_host/motion_native.rs b/crates/skate-game/src/graph_host/motion_native.rs index 0040cdb0..bb6ec285 100644 --- a/crates/skate-game/src/graph_host/motion_native.rs +++ b/crates/skate-game/src/graph_host/motion_native.rs @@ -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 handplant: Option<(skate_core::animation::output::attributes::AttributeName, [f32; 2])>, pub grab: Option<(skate_core::animation::output::attributes::AttributeName, [f32; 2])>, pub trick: Option, pub name: Option, diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs index 614c72a0..5543ab89 100644 --- a/crates/skate-game/src/graph_host/motion_nodes.rs +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -149,6 +149,9 @@ impl OperationFactory for MotionFactory { )), "ClearTrickAttr" => Some(MotionOperation::ClearTrickAttr), "SetTrickHeight" + | "FootPlantAbsorb" + | "SetHandPlantAnticLength" + | "ScoringHandPlants" | "SetTrickAttr" | "ScoringTrick" | "MonitorUnderflip" diff --git a/crates/skate-game/src/graph_host/motion_tricks.rs b/crates/skate-game/src/graph_host/motion_tricks.rs index c986f716..a0d7b128 100644 --- a/crates/skate-game/src/graph_host/motion_tricks.rs +++ b/crates/skate-game/src/graph_host/motion_tricks.rs @@ -10,6 +10,9 @@ use skate_data::state_graph::attributes::Attributes; #[derive(Clone, Debug, PartialEq)] pub enum Operation { + FootPlantAbsorb, + HandplantAntic, + HandplantScore { base: String, directions: [Option;4], intents: [String;2] }, Height { from: AttributeName, rename: AttributeName, @@ -26,6 +29,13 @@ pub enum Operation { impl Operation { pub fn parse(a: &Attributes<'_>) -> Option { Some(match a.text("name")? { + "FootPlantAbsorb" => Self::FootPlantAbsorb, + "SetHandPlantAnticLength" => Self::HandplantAntic, + "ScoringHandPlants" => Self::HandplantScore { + base:a.text("handplantname").unwrap_or("").to_owned(), + directions:["up","left","down","right"].map(|n|a.text(n).map(str::to_owned)), + intents:["intentX","intentY"].map(|n|a.text(n).unwrap_or("").to_owned()), + }, "SetTrickHeight" => Self::Height { from: encode(a.text("from").unwrap_or("").as_bytes()), rename: encode(a.text("rename").unwrap_or("").as_bytes()), @@ -56,6 +66,32 @@ pub fn execute( phase: u8, ) -> Result<(), String> { match operation { + Operation::HandplantAntic if phase==0 => { + //VT82321360 Begin=82BBE5D0; shared clip lengths from82B959E8. + let duration=|name|->Result { + let clip=host.animation.metadata().clip(name)?; + Ok((f32::from_bits(clip.frames_bits)-1.0)/(f32::from_bits(clip.base_speed_bits)*f32::from_bits(clip.fps_bits))) + }; + let normal=duration("INVERT_HANDPLANT_FS_0_ANTIC")?; + let late=duration("INVERT_HANDPLANT_FS_0_ANTIC_LATE")?; + let p=host.gameplay_conditions.as_ref().ok_or("Handplant antic requires physical output")?; + let value=((normal-(p.handplant_thresholds[2]-p.handplant_time)-late)/(normal-late)).clamp(0.0,1.0); + host.animation.set_attribute(SettableAttribute {name:encode(b"antic_length"),value,normalized:false,sequence_id:-1}); + } + Operation::HandplantScore {base,directions,intents} if phase==1 => { + //82BBF758 uses the same angular windows as ScoringGrabs82BBEF60. + let xy=intents.each_ref().map(|n|host.animation.filtered_intent(n).unwrap_or(0.0)); + let name=super::motion_stock_gameplay::select_grab_score(&base,directions.each_ref().map(|s|s.as_deref()),xy[0],xy[1]); + host.score_packet.handplant=Some((encode(name.as_bytes()),xy)); + } + Operation::FootPlantAbsorb if phase == 1 => { + //82BBE4E0 reads the manager's curve duration, Air212. + let value = host.gameplay_conditions.as_ref() + .ok_or("FootPlantAbsorb requires physical output")?.footplant_duration; + host.animation.set_attribute(SettableAttribute { + name: encode(b"absorblength"), value, normalized: false, sequence_id: -1, + }); + } Operation::Height { from, rename, diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index ea33a1e5..c86578c7 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -656,6 +656,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(), multiplayer: false, browser: false, maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0, diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 286b2293..1bbfa7b0 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -10,6 +10,9 @@ mod colliders; mod controls; mod foot_ik; mod footplant; +mod plant_skeleton; +mod boneless; +mod handplant; mod foot_ik_queries; mod foot_physical_output; pub(crate) mod ground; @@ -37,6 +40,7 @@ mod grind; mod ground_phase; mod ground_animation; mod slide_state; +mod revert_state; mod ground_exit; mod ground_runtime; mod input_phase; @@ -340,7 +344,8 @@ fn advance( ) { physics.failed = true; error!( - "{message}; tick={}; mapped_input={:?}; force_mode={}; board_axis_y={}; flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x}", + "{message}; state={:?}; tick={}; mapped_input={:?}; force_mode={}; board_axis_y={}; flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x}", + skater.player_state.current(), physics.ticks, input.0.actions(), skater.skeleton_input.force_mode, diff --git a/crates/skate-game/src/physics/air_reckoning.rs b/crates/skate-game/src/physics/air_reckoning.rs index 5ed417b2..c8c6bd9e 100644 --- a/crates/skate-game/src/physics/air_reckoning.rs +++ b/crates/skate-game/src/physics/air_reckoning.rs @@ -16,6 +16,10 @@ pub(crate) struct AirReckoning { modes: [settings::Mode; 5], } impl AirReckoning { + pub fn update_plant(&mut self,riding:&mut RidingOutputs,p:&ProcessedPhysicsInput,up:[f32;4],heading:[f32;4]) { + reckoning::update_plant(&mut riding.reckoning,&mut riding.reckoning_frames, + &mut riding.body_spin,&mut self.state,&self.settings,up,heading,p.flags_2468&(1<<20)!=0); + } pub fn load(data: &Collections) -> Result { let (settings, modes) = settings::load(data)?; Ok(Self { diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs index 5e794057..1bbf415d 100644 --- a/crates/skate-game/src/physics/animation_phase.rs +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -99,6 +99,11 @@ pub(crate) fn advance( can_land_on_board: physical.off_board.flag_316 != 0, hippy_hurdling: physical.off_board.hippy_hurdling_317 != 0, handplant_flags: physical.air.handplant_flags_324, + handplant_time: physical.air.handplant_time_320, + handplant_thresholds: skater.handplant.animation_thresholds(), + footplant_active: physical.air.flag_448 != 0, + footplant_duration: physical.air.footplant_duration_212, + footplant_contact_time: physical.air.footplant_contact_time_208, time_to_skitch: physical.ground.scalar_276, skitch_transition_time: profile.skitch_transition_time, time_to_land: physical.air.scalar_184, @@ -203,6 +208,7 @@ pub(crate) fn advance( facing_backwards: physics.grind.name.starts_with("BF_"), dropping_in: physical.grinds.dropping_in_324 != 0, }; + skater.animation.action.dropping_in = Some(physical.grinds.dropping_in_324 != 0); let conditions = ConditionInputs { speeds: Some(physics.riding.graph_speeds()), physical_state: Some(PhysicalStateInputs { diff --git a/crates/skate-game/src/physics/boneless.rs b/crates/skate-game/src/physics/boneless.rs new file mode 100644 index 00000000..c73deb03 --- /dev/null +++ b/crates/skate-game/src/physics/boneless.rs @@ -0,0 +1,103 @@ +//! Physical state602, TU3 vtable82327434. Stock no-comply/boneless/fastplant +//! animations select this state with AnimSkateboard and launch with FootJump. +use super::footplant::math::*; +use super::{GamePhysics, SkaterRuntime, air_phase, plant_skeleton}; +use skate_core::point_graph::PointGraph; +use skate_data::collections::Collections; + +pub(crate) struct Boneless { + pub toe: usize, //52 + pub anchor: [f32; 4], //64 + pub right: bool, //80 + curves: [PointGraph<8>; 4], +} +impl Boneless { + pub fn load(data: &Collections) -> Result { + let graph = |name| -> Result<_, String> { + let words = data + .words::<20>("Hash_CCB95A83C78B4FF9", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[4..12].try_into().unwrap(), + y: words[12..20].try_into().unwrap(), + }) + }; + Ok(Self { + toe: 15, + anchor: [0.0; 4], + right: false, + curves: [ + graph("Hash_6D781EFAF01E707D")?, + graph("Hash_C9112CCD0BCB1850")?, + graph("Hash_88D0CDEFA38A36D6")?, + graph("Hash_9228B7F18C223F9C")?, + ], + }) + } +} + +///82D4C900: anchor the last completed physical toe position. +pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.ground_lifecycle.board_animated_290 = 1; + let drive = &mut physics.board.hook_mut().drive; + //Globals12/912 at822F860C/822F8990; linear HardDrive, angular SoftDrive. + drive.dynamics[..4].copy_from_slice(&[0x426f_ffff, 0, 0x4560_fffe, 2]); + drive.enable_angular_soft(); + let right = skater.player_input.processed.flags_2468 & (1 << 26) != 0; + skater.boneless.toe = if right { 19 } else { 15 }; + skater.boneless.anchor = skater.skeleton.record.pose[skater.boneless.toe][3]; + skater.boneless.right = right; + Ok(()) +} + +///82D4C9B8. Authored FootJump controls every launch request; no host timer. +pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + plant_skeleton::advance( + physics, + skater, + skater.boneless.anchor, + Some(skater.boneless.toe), + )?; + plant_skeleton::hold_foot(skater, skater.boneless.right, skater.boneless.anchor, 3); + if skater.player_input.processed.flags_2480 & (1 << 13) != 0 { + launch(physics, skater)?; + } + Ok(()) +} + +///82D4CB18/82D4CC18, four PointNegGraphData8 values from the stock schema. +fn launch(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let up = p.vectors_544_560_592_608[0].map(f32::from_bits); + let current = p.vectors_544_560_592_608[3].map(f32::from_bits); + let prepared = p.prepared_jump_704.map(f32::from_bits); + let horizontal = sub(prepared, scale(up, dot(prepared, up))); + let speed = length(horizontal); + let g = &skater.boneless.curves; + let vertical = g[0].evaluate(up[1]) * g[1].evaluate(speed); + let scaled_speed = g[2].evaluate(speed) * speed; + let a = madd(up, vertical - dot(current, up), current); + let b = madd(normalize(horizontal), scaled_speed, scale(up, vertical)); + let blend = g[3].evaluate(up[1]); + let mut velocity = madd(b, blend, scale(a, 1.0 - blend)); + if (length(velocity) - length(current)).abs() > 10.0 { + velocity = current; + } + let forward = p.effective_anim_transform_192[2].map(f32::from_bits); //Processed224 + let component = dot(velocity, forward).max(0.0); + if component < 1.92 { + velocity = madd(forward, 1.92 - component, velocity); + } + let com = p.vectors_544_560_592_608[2].map(f32::from_bits); + let position = madd(up, dot(sub(skater.boneless.anchor, com), up) + 0.25, com); + let mut info = air_phase::launch_info(physics, skater)?; + info.start_velocity = velocity; + info.start_position_override = position; + info.board_position_override = position; + info.use_position_override = true; + info.player_jumped = true; + let input = air_phase::selector_input(physics, skater)?; + skater.trajectory.launch(info, input, &physics.world)?; + skater.trajectory.update(input, &physics.world)?; + Ok(()) +} diff --git a/crates/skate-game/src/physics/footplant.rs b/crates/skate-game/src/physics/footplant.rs index 9645c505..c8d94cea 100644 --- a/crates/skate-game/src/physics/footplant.rs +++ b/crates/skate-game/src/physics/footplant.rs @@ -2,10 +2,11 @@ //! Query results are retained separately from pending query ownership. use skate_core::air::trajectory::{QueryResult, Trajectory}; use skate_data::collections::Collections; -mod math; +pub(super) mod math; mod pose; mod settings; mod update; +pub(super) mod ground; use settings::Settings; pub(crate) use update::FootplantFrame; pub(crate) type V = [f32; 4]; @@ -36,11 +37,13 @@ pub(crate) struct Footplant { pub(super) selected_record: V, //272 pub(super) leg_direction: V, //336 pub(super) vectors_352_368: [V; 2], + pub(super) curve: [V; 4], //448/464/480/496 pub(super) physical_com: V, //384 pub(super) animation_com: V, //400 pub(super) launch_direction: V, //416 pub(super) locked_target: V, //432 pub(super) request: Trajectory, //528..591 + pub(super) completed_trajectory: Trajectory, //query144, retained with result } impl Footplant { pub fn load(data: &Collections) -> Result { @@ -73,6 +76,7 @@ impl Footplant { selected_record: [0.0; 4], leg_direction: [0.0; 4], vectors_352_368: [[0.0; 4]; 2], + curve: [[0.0; 4]; 4], physical_com: [0.0; 4], animation_com: [0.0; 4], launch_direction: [0.0; 4], @@ -83,6 +87,7 @@ impl Footplant { acceleration: [0.0; 4], duration: -1.0, }, + completed_trajectory: Trajectory { position: [0.0;4], velocity: [0.0;4], acceleration: [0.0;4], duration: -1.0 }, }; state.reset(); Ok(state) @@ -123,4 +128,17 @@ impl Footplant { self.enabled = false; self.reset(); } + /// Common ProcessOutput82DB71B0 calls82D71040 before selected state Fill. + pub fn publish(&self, out: &mut skate_core::player::input_phase::AirOutputFields) { + if self.hit { + out.footplant_contact_time_208 = self.contact_time; + out.footplant_duration_212 = self.scalar_596; + out.flag_447 = u8::from(self.perform); + out.flag_448 = u8::from(self.perform || self.flag_627); + out.footplant_surface_224 = self.surface; + out.footplant_left_449 = u8::from(self.flag_627 && self.selected_toe == Some(15)); + out.footplant_right_450 = u8::from(self.flag_627 && self.selected_toe == Some(19)); + out.footplant_surface_height_216 = self.contact[1]; + } + } } diff --git a/crates/skate-game/src/physics/footplant/ground.rs b/crates/skate-game/src/physics/footplant/ground.rs new file mode 100644 index 00000000..73dcedb5 --- /dev/null +++ b/crates/skate-game/src/physics/footplant/ground.rs @@ -0,0 +1,180 @@ +//! FootPlant601, vtable82327400 and manager82D704B0/82D70E40. +use super::{Footplant, math::*}; +use crate::physics::{GamePhysics, SkaterRuntime, air_phase, plant_skeleton}; + +pub(in crate::physics) fn enter( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut skater.ground_lifecycle.board_animated_290); + //82D91330 sets the foot/toe volume group and clears their disable counters. + for part in [15, 16, 19, 20] { + skater.skeleton_collision.parts[part].volume_group = 4; + skater.skeleton_collision.parts[part].enabled = false; + skater.skeleton_collision.disable_count[part] = 0; + } + let p = &skater.player_input.processed; + let frames = skater.footplant.start( + p.vectors_544_560_592_608[2].map(f32::from_bits), + p.vectors_544_560_592_608[3].map(f32::from_bits), + ); + if frames != 0 { + //The native Start only disables collisions; the first Update sets IK. + let right = skater.footplant.selected_toe == Some(19); + for part in if right { [19, 20, 21] } else { [15, 16, 17] } { + skater.skeleton_collision.pending_reenable = true; + skater.skeleton_collision.disable_count[part] = frames; + skater.skeleton_collision.parts[part].enabled = false; + } + } + Ok(()) +} + +impl Footplant { + fn start(&mut self, com: [f32; 4], velocity: [f32; 4]) -> u32 { + self.perform = false; + self.scalar_600 = 0.0; + self.flag_627 = true; + let contact = self.adjusted_contact; + self.vectors_352_368[1] = contact; + let offset = sub(com, contact); + let radial = normalize(offset); + let multiplier = self + .settings + .radial_speed_scale + .evaluate(length(scale(radial, dot(velocity, radial)))); + let rotation = cross(offset, velocity); + let angular_speed = length(rotation); + //830BD300 is initialized by82F82690 from821647E0. + if !(angular_speed > f32::from_bits(0x3780_0000)) { + return 0; + } + let axis = scale(rotation, reciprocal(angular_speed)); + let angular_rate = angular_speed * multiplier; + //8286CD88 projects both vectors into the plane;8296EC98 signs acos. + let normal = scale(axis, -1.0); + let up = [0.0, 1.0, 0.0, 0.0]; + let projected_up = normalize(sub(up, scale(normal, dot(up, normal)))); + let mut angle = if length(projected_up) == 0.0 { + 0.0 + } else { + skate_core::trigonometry::acos(dot(radial, projected_up).clamp(-1.0, 1.0)) + }; + if dot(cross(radial, projected_up), normal) < 0.0 { + angle = -angle; + } + let min_end = angle + angular_rate * self.settings.min_duration; + let max_end = angle + angular_rate * self.settings.max_duration; + let end_angle = min_end + .max(self.settings.end_angle * f32::from_bits(0x3c8e_fa35)) + .min(max_end); + let delta = end_angle - angle; + self.scalar_596 = delta / angular_rate; + //Quaternion(axis,delta) applied in the native two-cross-product order. + let (sin, cos) = skate_core::trigonometry::sin_cos(delta * 0.5); + let q = scale(axis, sin); + let end_radial = madd(cross(q, madd(radial, cos, cross(q, radial))), 2.0, radial); + let end = madd(end_radial, self.settings.end_leg_length, contact); + let middle = normalize(add(end_radial, radial)); + let release = madd( + sub(velocity, scale(middle, dot(velocity, middle))), + multiplier, + scale(middle, self.settings.release_outward_speed), + ); + self.scalar_612 = length(release); + self.vectors_352_368[0] = scale(release, reciprocal(self.scalar_612)); + self.curve = [ + com, + madd( + scale(velocity, multiplier), + self.scalar_596 * self.settings.start_handle, + com, + ), + sub( + end, + scale(release, self.scalar_596 * self.settings.end_handle), + ), + end, + ]; + (4 - (self.scalar_596 * f32::from_bits(0xc26f_ffff)) as i32) as u32 + } + + ///82D70CE0: authored BodyAdjustX/Z move the release point, retaining + ///the last handle length.82C1E170 removes only positive up projection. + fn adjust(&mut self, t: f32, input: [f32; 2]) { + let amount = (1.0 - t).mul_add(0.02, t * 0.01); + let mut movement = [input[0] * amount, 0.0, input[1] * amount, 0.0]; + let projection = dot(movement, normalize(self.current_up)); + if projection > 0.0 { + movement = sub(movement, scale(self.current_up, projection)); + } + let previous = self.curve[3]; + self.curve[3] = add(previous, movement); + let direction = normalize(sub(self.curve[3], self.curve[2])); + self.curve[2] = sub( + self.curve[3], + scale(direction, length(sub(self.curve[2], previous))), + ); + self.vectors_352_368[0] = direction; + } +} + +pub(in crate::physics) fn update( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let fp = &mut skater.footplant; + fp.scalar_600 += STEP; + let t = fp.scalar_600 / fp.scalar_596; + fp.adjust(t, skater.animation_input.extra.body_adjust); + let u = 1.0 - t; + let position = madd( + fp.curve[3], + t * t * t, + madd( + fp.curve[2], + 3.0 * t * t * u, + madd(fp.curve[0], u * u * u, scale(fp.curve[1], 3.0 * t * u * u)), + ), + ); + let launched = fp.scalar_600 > fp.scalar_596; + if launched { + fp.flag_627 = false; + } + let anchor = fp.vectors_352_368[1]; + let velocity = scale(fp.vectors_352_368[0], fp.scalar_612); + let right = fp.selected_toe != Some(15); + plant_skeleton::hold_foot(skater, right, anchor, 0); + plant_skeleton::advance(physics, skater, position, None)?; + if launched { + let mut info = air_phase::launch_info(physics, skater)?; + info.start_velocity = velocity; + info.start_position_override = add(anchor, [0.0, 0.2, 0.0, 0.0]); + info.board_position_override = add(info.start_position_override, [0.0, -0.1, 0.0, 0.0]); + info.use_position_override = true; + info.player_jumped = true; + let input = air_phase::selector_input(physics, skater)?; + skater.trajectory.launch(info, input, &physics.world)?; + skater.trajectory.update(input, &physics.world)?; + } + Ok(()) +} + +///82D4C6D8, after the shared plant collision check82D8FDC0. +pub(in crate::physics) fn post_physics(skater: &mut SkaterRuntime) { + let p = &skater.player_input.processed; + let down = scale(p.vectors_544_560_592_608[0].map(f32::from_bits), -1.0); + let velocity = p.vectors_544_560_592_608[3].map(f32::from_bits); + let descending = dot(velocity, down); + let horizontal = length(sub(velocity, scale(down, descending))); + if descending > skater.footplant.settings.max_descending_speed { + skater.wipeout.state.request(6, descending); + } + if horizontal > skater.footplant.settings.max_horizontal_speed { + skater.wipeout.state.request(23, 0.0); + } +} diff --git a/crates/skate-game/src/physics/footplant/math.rs b/crates/skate-game/src/physics/footplant/math.rs index 6873c422..fc9ed161 100644 --- a/crates/skate-game/src/physics/footplant/math.rs +++ b/crates/skate-game/src/physics/footplant/math.rs @@ -1,23 +1,26 @@ //! Original vector operation order from82D6FD60/82D6F5F0/82D70070. //! Estimate seeds use independent PC math; Xenon bit equality is unverified. use super::V; -pub(super) const STEP: f32 = f32::from_bits(0x3c888889); -pub(super) fn dot(a: V, b: V) -> f32 { +pub(in crate::physics) const STEP: f32 = f32::from_bits(0x3c888889); +pub(in crate::physics) fn dot(a: V, b: V) -> f32 { (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] } -pub(super) fn add(a: V, b: V) -> V { +pub(in crate::physics) fn cross(a: V, b: V) -> V { + [a[1].mul_add(b[2], -a[2]*b[1]), a[2].mul_add(b[0], -a[0]*b[2]), a[0].mul_add(b[1], -a[1]*b[0]), 0.0] +} +pub(in crate::physics) fn add(a: V, b: V) -> V { std::array::from_fn(|i| a[i] + b[i]) } -pub(super) fn sub(a: V, b: V) -> V { +pub(in crate::physics) fn sub(a: V, b: V) -> V { std::array::from_fn(|i| a[i] - b[i]) } -pub(super) fn scale(a: V, s: f32) -> V { +pub(in crate::physics) fn scale(a: V, s: f32) -> V { a.map(|v| v * s) } -pub(super) fn madd(a: V, s: f32, b: V) -> V { +pub(in crate::physics) fn madd(a: V, s: f32, b: V) -> V { std::array::from_fn(|i| a[i].mul_add(s, b[i])) } -pub(super) fn reciprocal(s: f32) -> f32 { +pub(in crate::physics) fn reciprocal(s: f32) -> f32 { let mut r = s.recip(); for _ in 0..2 { r = (-s).mul_add(r, 1.0).mul_add(r, r); @@ -31,12 +34,12 @@ fn rsqrt(s: f32) -> f32 { } r } -pub(super) fn length(a: V) -> f32 { +pub(in crate::physics) fn length(a: V) -> f32 { let s = dot(a, a); let l = s * rsqrt(s); if s == 0.0 { 0.0 } else { l } } -pub(super) fn normalize(a: V) -> V { +pub(in crate::physics) fn normalize(a: V) -> V { let s = dot(a, a); let r = rsqrt(s); let l = if s == 0.0 { 0.0 } else { s * r }; @@ -46,21 +49,21 @@ pub(super) fn normalize(a: V) -> V { [0.0; 4] } } -pub(super) fn point(m: &[V; 4], p: V) -> V { +pub(in crate::physics) fn point(m: &[V; 4], p: V) -> V { std::array::from_fn(|i| { let x = m[0][i].mul_add(p[0], m[3][i]); let y = m[1][i].mul_add(p[1], x); m[2][i].mul_add(p[2], y) }) } -pub(super) fn rotate(m: &[V; 4], v: V) -> V { +pub(in crate::physics) fn rotate(m: &[V; 4], v: V) -> V { std::array::from_fn(|i| { let x = m[0][i] * v[0]; let y = m[1][i].mul_add(v[1], x); m[2][i].mul_add(v[2], y) }) } -pub(super) fn clamp_length(v: V, maximum: f32) -> V { +pub(in crate::physics) fn clamp_length(v: V, maximum: f32) -> V { let l = length(v); if !(l >= f32::from_bits(0x37800000)) { return v; @@ -68,7 +71,7 @@ pub(super) fn clamp_length(v: V, maximum: f32) -> V { let capped = if maximum - l >= 0.0 { l } else { maximum }; scale(scale(v, capped), reciprocal(l)) } -pub(super) fn clamp01(v: f32) -> f32 { +pub(in crate::physics) fn clamp01(v: f32) -> f32 { let a = if -v >= 0.0 { 0.0 } else { v }; if 1.0 - a >= 0.0 { a } else { 1.0 } } diff --git a/crates/skate-game/src/physics/footplant/settings.rs b/crates/skate-game/src/physics/footplant/settings.rs index 4d831416..c1b4913e 100644 --- a/crates/skate-game/src/physics/footplant/settings.rs +++ b/crates/skate-game/src/physics/footplant/settings.rs @@ -1,15 +1,27 @@ //! Cached physics_footplantmanager layout used by82D6FD60/82D6F5F0/82D70070. use skate_data::collections::Collections; +use skate_core::point_graph::PointGraph; pub(super) struct Settings { pub deck_bounds: [f32; 4], //0 pub max_leg_angle_error: f32, //280 degrees pub leg_length_on_landing: f32, //288 pub foot_volume_y_offset: f32, //296 pub deck_bounds_y_offset: f32, //300 + pub radial_speed_scale: PointGraph<8>, //16 + pub max_horizontal_speed: f32, //256 + pub max_descending_speed: f32, //260 + pub release_outward_speed: f32, //268 + pub end_handle: f32, //272 + pub end_angle: f32, //276 degrees + pub end_leg_length: f32, //284 + pub start_handle: f32, //292 + pub min_duration: f32, //304 + pub max_duration: f32, //308 } impl Settings { pub fn load(data: &Collections) -> Result { let float = |name| data.float("physics_footplantmanager", "default", name); + let graph = data.words::<20>("physics_footplantmanager", "default", "Hash_8A8887218466ED15")?.map(f32::from_bits); Ok(Self { deck_bounds: data .words::<4>("physics_footplantmanager", "default", "DeckBB")? @@ -18,6 +30,16 @@ impl Settings { leg_length_on_landing: float("LegLengthOnLanding")?, foot_volume_y_offset: float("FootVolumeYOffset")?, deck_bounds_y_offset: float("DeckBBYOffset")?, + radial_speed_scale: PointGraph { x: graph[4..12].try_into().unwrap(), y: graph[12..20].try_into().unwrap() }, + max_horizontal_speed: float("Hash_149A5D0D4768D2B1")?, + max_descending_speed: float("Hash_52436D9F4FE56BCB")?, + release_outward_speed: float("Hash_C5F92168DFAFD0B8")?, + end_handle: float("Hash_628C228DCC7B6ED5")?, + end_angle: float("Hash_4F45DA86685CF029")?, + end_leg_length: float("Hash_0DE0027B9913C5D5")?, + start_handle: float("Hash_5C4341EF35F4B2B0")?, + min_duration: float("Hash_D6224B710A36D643")?, + max_duration: float("Hash_68C05A7FCC6BB718")?, }) } } diff --git a/crates/skate-game/src/physics/footplant/update.rs b/crates/skate-game/src/physics/footplant/update.rs index 93d0a605..b41fa13d 100644 --- a/crates/skate-game/src/physics/footplant/update.rs +++ b/crates/skate-game/src/physics/footplant/update.rs @@ -22,6 +22,7 @@ pub(crate) struct FootplantFrame<'a> { pub body: &'a SkeletonBody, pub gravity: V, pub world: &'a BoardWorld, + pub edges: &'a [skate_core::physics::grind_contact::Primitive], } impl Footplant { pub fn update_candidate(&mut self, input: &KnownAirFootplantInput, frame: &FootplantFrame<'_>) { @@ -106,7 +107,7 @@ impl Footplant { self.contact = self.result.contact_position; self.surface = self.result.surface; self.hit = true; - //82D6FBB0's nearby-edge scan is empty in our explicitly edge-free BoardWorld. + self.nearby_edge(frame); self.adjusted_contact = madd( input.landing_normal, self.settings.foot_volume_y_offset, @@ -134,28 +135,61 @@ impl Footplant { } } self.current_up = frame.processed.vectors_544_560_592_608[0].map(f32::from_bits); + let submitted = Trajectory { + position: self.request.position, velocity: self.request.velocity, + acceleration: frame.gravity, duration: 1.0, + }; let next = AirTrajectoryRuntime::query( frame.world, QueryRequest { - trajectory: Trajectory { - position: self.request.position, - velocity: self.request.velocity, - acceleration: frame.gravity, - duration: 1.0, - }, + trajectory: submitted, radius: f32::from_bits(0x3d23d70a), start_error: 1.0, end_error: 1.0, }, )?; self.result = next; + self.completed_trajectory = submitted; self.enabled = true; Ok(()) } fn clear_contact(&mut self) { self.surface = 0; self.hit = false; - self.contact_time = -1.0; + self.contact_time = 0.0; //82D6FA80/82D6FAC4, distinct from Reset's -1. self.contact = [0.0; 4]; } + + ///82D6FBB0: query the real edge geometry within the native two-metre box. + fn nearby_edge(&mut self, frame: &FootplantFrame<'_>) { + use skate_core::{math::Vector3, player::offboard::{air_ledge::visible_edges, ground_query::Edge}}; + let xyz = |v: V| Vector3::new(v[0], v[1], v[2]); + let lanes = |v: Vector3| [v.x, v.y, v.z, 0.0]; + let edges: Vec<_> = frame.edges.iter().filter(|e| (0..3).all(|i| + e.start[i].min(e.end[i]) <= self.contact[i] + 2.0 && e.start[i].max(e.end[i]) >= self.contact[i] - 2.0 + )).map(|e| Edge { start: xyz(e.start), end: xyz(e.end) }).collect(); + let original_height = self.contact[1]; + let mut best = 0.25; //820C6D98 + for edge in visible_edges(&edges, frame.toolkit.deck[3]) { + let start = lanes(edge.start); + let delta = sub(lanes(edge.end), start); + let raw_normal = cross(cross(delta, [0.0,1.0,0.0,0.0]), delta); + let normal = if length(raw_normal) > f32::from_bits(0x3586_37bd) { normalize(raw_normal) } else { [0.0,1.0,0.0,0.0] }; + let Some(time) = skate_core::air::trajectory::grind::descending_plane_time(self.completed_trajectory, start, normal) else { continue; }; + let arc = self.completed_trajectory.position_at(time); + let edge_length = length(delta); + let direction = if edge_length > f32::from_bits(0x3780_0000) { scale(delta, reciprocal(edge_length)) } else { delta }; + let point = madd(direction, dot(direction, sub(arc, start)).min(edge_length).max(0.0), start); + let distance = length(sub(arc, point)); + //Globals3644 at822F943C permits at most 3cm below the triangle hit. + if distance < best && point[1] - original_height > f32::from_bits(0xbcf5_c28f) { + self.contact_time = time; + self.current_up = normal; + self.contact = point; + self.surface = 0; + self.hit = true; + best = distance; + } + } + } } diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 1cb2b684..c22a3d04 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -96,6 +96,14 @@ pub(super) fn advance( player_state::post_input_and_select(physics, skater)?; let state_after_selection = skater.player_state.current(); if state_before_selection != state_after_selection { + if skater.player_input.processed.flags_2476 & (1 << 22) != 0 + || state_before_selection == skate_core::player::state::PhysicalStateId::HandPlant + || state_after_selection == skate_core::player::state::PhysicalStateId::HandPlant { + bevy::log::info!("HANDPLANT_STATE tick={tick} from={state_before_selection:?} to={state_after_selection:?} flags={:08x} phase={} processed={:08x}/{:08x}/{:08x}", + skater.handplant.flags, skater.handplant.phase, + skater.player_input.processed.flags_2468, skater.player_input.processed.flags_2476, + skater.player_input.processed.flags_2480); + } physics.exchange.emit_event( tick, PhysicsEvent::StateChanged { @@ -106,6 +114,10 @@ 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)?, skate_core::player::state::PhysicalStateId::GrindBoardslide | skate_core::player::state::PhysicalStateId::GrindFiftyFifty | skate_core::player::state::PhysicalStateId::GrindTipslide @@ -136,6 +148,7 @@ pub(super) fn advance( //after Reckoning; this frame's propulsion may then set it again. skater.ground.state.push_suppressed_2730 = false; ground_phase::advance(physics, skater)?; + super::handplant::ground_update(physics,skater)?; input_phase::update_ground(physics, skater)?; } skate_core::player::state::PhysicalStateId::PhysicsAir => { diff --git a/crates/skate-game/src/physics/ground_phase.rs b/crates/skate-game/src/physics/ground_phase.rs index d7535a91..5ab412ea 100644 --- a/crates/skate-game/src/physics/ground_phase.rs +++ b/crates/skate-game/src/physics/ground_phase.rs @@ -164,6 +164,8 @@ pub(crate) fn advance( physics: &mut GamePhysics, skater: &mut SkaterRuntime, ) -> Result { + super::handplant::ground_query(physics,skater); + let selector_input = super::air_phase::selector_input(physics, skater)?; let p = &skater.player_input.processed; let toolkit = skater .player_input @@ -196,8 +198,11 @@ pub(crate) fn advance( Ok(()) }; let mut launch = |info: &GroundLaunchInfo| { - life.pending_wall_jump = Some(info.clone()); - Err("Ground selected wall jump; its retained launch requires TrajectorySelector::Launch/Update".into()) + let mut input = selector_input; + input.board_vertical_velocity = info.velocity[1]; + skater.trajectory.launch(info.selector_launch(), input, &physics.world)?; + skater.trajectory.update(input, &physics.world)?; + Ok(()) }; let physical = GroundPhysicalFrame { skeleton_record: &skater.animated_skeleton.record, @@ -267,7 +272,6 @@ pub(crate) fn advance( skeleton_elapsed_16505: &mut life.skeleton_elapsed_16505, skeleton_ground_16388: &mut life.skeleton_ground_16388, // Original830BD4A0 initializer82F825F0 splats8216DEE0=-1. - world_grab_reset_direction: [-1.0; 4], move_future_deck: &mut move_future, trajectory: &mut life.trajectory, }, diff --git a/crates/skate-game/src/physics/ground_runtime/launch.rs b/crates/skate-game/src/physics/ground_runtime/launch.rs index 5a620f60..6b93c51f 100644 --- a/crates/skate-game/src/physics/ground_runtime/launch.rs +++ b/crates/skate-game/src/physics/ground_runtime/launch.rs @@ -60,6 +60,21 @@ pub(crate) struct GroundLaunchPhysical<'a> { pub time_step: f32, } impl GroundLaunchInfo { + ///The retained Ground packet and the shared selector packet have the same + ///82D33448 layout. Preserve both velocities and all override flags. + pub fn selector_launch(&self) -> skate_core::air::trajectory::LaunchInfo { + skate_core::air::trajectory::LaunchInfo { + reckoning_transform:self.system, reckoning_inverse:self.inverse_system, + start_velocity:self.velocity, com_velocity:self.angular_velocity, + skeleton_vector_160:self.skeleton_vector_16208, + skeleton_vector_176:self.skeleton_vector_16240, + board_position:self.board_position, animation_com_position:self.physical_center_of_mass, + start_position_override:self.vector_224, board_position_override:self.vector_240, + cone_angle_x:self.cone_angle_x, cone_angle_z:self.cone_angle_z, + timestep:self.time_step, player_jumped:self.wall_jump, + use_position_override:self.flag_269, trajectory_count:self.flags_270, + } + } pub fn fill(&mut self, p: &GroundLaunchPhysical<'_>, cone_angle_x: f32, cone_angle_z: f32) { self.board_position = p.board_position; self.physical_center_of_mass = p.physical_center_of_mass; @@ -81,3 +96,26 @@ impl GroundLaunchInfo { self.wall_jump = true; } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn wall_jump_selector_receives_launch_velocity_and_physical_anchors() { + let mut info = GroundLaunchInfo { board_position:[1.0,2.0,3.0,0.0], + physical_center_of_mass:[1.2,3.0,3.1,0.0], + skeleton_vector_16208:[0.2,0.8,0.1,0.0], flags_270:7, + time_step:1.0/60.0, ..Default::default() }; + let velocity=[2.0,4.0,-3.0,0.0]; + info.wall_jump(velocity); + let launch=info.selector_launch(); + assert!(launch.player_jumped); + assert!(!launch.use_position_override); + assert_eq!(launch.start_velocity,velocity); + assert_eq!(launch.com_velocity,velocity); + assert_eq!(launch.board_position,info.board_position); + assert_eq!(launch.animation_com_position,info.physical_center_of_mass); + assert_eq!(launch.skeleton_vector_160,info.skeleton_vector_16208); + assert_eq!(launch.trajectory_count,7); + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/mod.rs b/crates/skate-game/src/physics/ground_runtime/mod.rs index 01a2c904..ffcee36e 100644 --- a/crates/skate-game/src/physics/ground_runtime/mod.rs +++ b/crates/skate-game/src/physics/ground_runtime/mod.rs @@ -4,7 +4,6 @@ mod corrections; mod entry; mod state; mod update; -mod world_grab; pub(crate) use update::{GroundUpdateFrame, GroundUpdateTargets}; mod trajectory; pub(crate) use entry::GroundEntryTargets; diff --git a/crates/skate-game/src/physics/ground_runtime/pumping.rs b/crates/skate-game/src/physics/ground_runtime/pumping.rs index 8e9b3993..28af6bf9 100644 --- a/crates/skate-game/src/physics/ground_runtime/pumping.rs +++ b/crates/skate-game/src/physics/ground_runtime/pumping.rs @@ -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 { let f = |field| data.float("physics_pumping", "default", field); let c = |field| super::settings::curve8(data, "physics_pumping", "default", field); diff --git a/crates/skate-game/src/physics/ground_runtime/state.rs b/crates/skate-game/src/physics/ground_runtime/state.rs index 575bef46..aecd56fd 100644 --- a/crates/skate-game/src/physics/ground_runtime/state.rs +++ b/crates/skate-game/src/physics/ground_runtime/state.rs @@ -20,7 +20,6 @@ use skate_core::{ use skate_data::collections::Collections; pub(crate) struct GroundState { - pub world_grab: super::world_grab::GroundWorldGrabState, pub state: PhysicsGroundState, pub pumping: PumpingState, pub wobble: SpeedWobbleState, @@ -38,7 +37,6 @@ impl GroundState { pub fn load(data: &Collections, _mode: &str, human_player: bool) -> Result { let push = |field| data.float("physics_push", "default", field); Ok(Self { - world_grab: super::world_grab::GroundWorldGrabState::default(), state: PhysicsGroundState::before_first_enter(human_player), pumping: PumpingState::reset_state(), wobble: SpeedWobbleState([0; 8]), diff --git a/crates/skate-game/src/physics/ground_runtime/update.rs b/crates/skate-game/src/physics/ground_runtime/update.rs index 70892998..376a6c8d 100644 --- a/crates/skate-game/src/physics/ground_runtime/update.rs +++ b/crates/skate-game/src/physics/ground_runtime/update.rs @@ -1,6 +1,6 @@ //! Ordinary Ground82D37C88 executes persistent controller updates and actual -//!body forces before prediction and IK publication. Skitch's active query and -//!force path is a distinct behavior; absent world data cannot simulate it. +//!body forces before prediction and IK publication. Ground's handplant query +//!82D38430 is dispatched by ground_phase through the retained Handplant owner. use super::{ super::{ animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, foot_ik::FootIk, @@ -32,7 +32,6 @@ pub(crate) struct GroundUpdateTargets<'a, T> { pub foot_ik: &'a mut FootIk, pub skeleton_elapsed_16505: &'a mut bool, pub skeleton_ground_16388: &'a mut bool, - pub world_grab_reset_direction: [f32; 4], ///Applies to SkeletonDrives hook432 body and Skeleton16112 together. pub move_future_deck: &'a mut dyn FnMut(Vector3) -> Result<(), String>, pub trajectory: &'a mut GroundTrajectoryState, @@ -58,11 +57,10 @@ impl GroundState { if signals.set_skeleton_flag_16505 { *targets.skeleton_elapsed_16505 = true; } - if p.flags_2476 & 0x0040_0000 != 0 { - return Err("Active world-grab requires recovered Skitch query/force behavior; ordinary Ground cannot invent an object".into()); - } - self.world_grab - .clear_inactive(targets.world_grab_reset_direction); + //GrabWorld (2476 bit22) requests Handplant::ground_query82D38430, + //already submitted by ground_phase. It is not proof of a Skitch object. + //The same Handplant owner handles the inactive reset and a query miss; + //ordinary ground forces continue for both held and released input. let pumping_mode = self.pumping_settings.mode(p.state_variant_index_2528)?; self.pumping_settings.update( &mut self.pumping, diff --git a/crates/skate-game/src/physics/ground_runtime/world_grab.rs b/crates/skate-game/src/physics/ground_runtime/world_grab.rs deleted file mode 100644 index 0011f46a..00000000 --- a/crates/skate-game/src/physics/ground_runtime/world_grab.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Typed Skitch/world-grab observations. Ground inactive82D38430 executes -//!Stop82D62F20 and predictor reset82D65FB8 before pumping. A target is present -//!only when the world query owner supplied an actual object observation. -#[derive(Clone, Debug)] -pub(crate) struct WorldGrabObservation { - pub object: u64, - pub position: [f32; 4], - pub relative_velocity: [f32; 4], -} -#[derive(Clone, Debug, Default)] -pub(crate) struct GroundWorldGrabState { - pub observation: Option, - pub contact_flags: u32, - pub contact_time: f32, - pub contact_position: [f32; 4], - pub contact_velocity: [f32; 4], - pub contact_valid: bool, - pub active: bool, - pub secondary_active: bool, - pub selected_count: u32, - pub position: [f32; 4], - pub probe_distances: [[f32; 4]; 4], - pub probe_weights: [[f32; 4]; 2], - pub query_points: [[f32; 4]; 2], - pub query_normals: [[f32; 4]; 2], - pub query_word: u32, - pub query_scalar: f32, - pub query_valid: [bool; 2], - pub output_scalar: f32, - pub output_direction: [f32; 4], - pub output_flags: u32, -} -impl GroundWorldGrabState { - ///The shared vector comes from native global830BD4A0's initializer. It - ///must be supplied by that producer, never inferred from ground normal. - pub fn clear_inactive(&mut self, reset_direction: [f32; 4]) { - self.active = false; - self.secondary_active = false; - self.contact_flags &= 0x4fff_ffff; - self.contact_time = f32::MAX; - self.contact_position = [0.0; 4]; - self.contact_valid = false; - self.contact_velocity = [0.0; 4]; - self.observation = None; - self.probe_distances = [[-1.0; 4]; 4]; - self.probe_weights = [[0.0; 4]; 2]; - self.selected_count = 0; - self.position = [0.0; 4]; - //82D65FB8 writes two literal UnitY normals and two zero points. - self.query_points = [[0.0; 4]; 2]; - self.query_normals = [[0.0, 1.0, 0.0, 0.0]; 2]; - self.query_word = 0; - self.query_scalar = 0.0; - self.query_valid = [false; 2]; - self.output_scalar = 0.0; - self.output_direction = reset_direction; - self.output_flags &= 0x3fff_ffff; - } -} diff --git a/crates/skate-game/src/physics/handplant.rs b/crates/skate-game/src/physics/handplant.rs new file mode 100644 index 00000000..17d37045 --- /dev/null +++ b/crates/skate-game/src/physics/handplant.rs @@ -0,0 +1,310 @@ +//! TU3 HandPlantManager82D61040 and physical state600 (823273CC). +mod contact; +mod ik; +mod rotation; +mod settings; +mod trajectory; +use super::footplant::math; +use super::{GamePhysics, SkaterRuntime, plant_skeleton}; +use math::*; +use settings::Settings; +use skate_core::{air::trajectory::Trajectory, physics::skeleton_animation_record::IDENTITY}; +type V = [f32; 4]; +const UP: V = [0.0, 1.0, 0.0, 0.0]; +const GRAVITY: V = [0.0, f32::from_bits(0xc11ccccd), 0.0, 0.0]; +const EMPTY: Trajectory = Trajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + duration: -1.0, +}; + +pub(crate) struct Handplant { + settings: Settings, + pub flags: u32, + pub phase: f32, + pub anchor: V, + previous_candidate_point: V, //2192; separate from the published anchor1856. + pending: Option<(contact::Candidate, V, V, V, V)>, + candidate: Option, + initial: Trajectory, + entry: Trajectory, + outgoing: [Trajectory; 2], + curve: [V; 4], + rotations: [[V; 4]; 4], + direction: V, + travel_sign: f32, + elapsed: f32, + warped: f32, + apex: f32, + estimated_phase: f32, //2944; retained when the authored mean is zero. + out_duration: f32, + pub continuation: bool, + direction_hint: i32, + direction_count: i32, + ik_blend: f32, + ik_distance: f32, + ik_released: bool, + ik_latched: bool, +} +impl Handplant { + pub fn load(data: &skate_data::collections::Collections) -> Result { + Ok(Self { + settings: Settings::load(data)?, + flags: 0, + phase: f32::MAX, + anchor: [0.0; 4], + previous_candidate_point: [0.0; 4], + pending: None, + candidate: None, + initial: EMPTY, + entry: EMPTY, + outgoing: [EMPTY; 2], + curve: [[0.0; 4]; 4], + rotations: [IDENTITY; 4], + direction: [0.0; 4], + travel_sign: 0.0, + elapsed: -1.0, + warped: -1.0, + apex: -1.0, + estimated_phase: -1.0, + out_duration: 0.0, + continuation: true, + direction_hint: 0, + direction_count: 0, + ik_blend: 0.0, + ik_distance: -1.0, + ik_released: false, + ik_latched: false, + }) + } + pub fn animation_thresholds(&self) -> [f32; 3] { + self.settings.animation + } + ///82D62F20 preserves the new-position bit and the stored trajectories. + pub fn reset(&mut self) { + self.flags &= !0xb000_0000; + self.phase = f32::MAX; + self.anchor = [0.0; 4]; + self.pending = None; + self.candidate = None; + self.elapsed = -1.0; + self.warped = -1.0; + self.apex = -1.0; + self.estimated_phase = -1.0; + self.out_duration = 0.0; + self.direction = [0.0; 4]; + self.continuation = true; + } + pub fn full_reset(&mut self) { + self.reset(); + self.reset_ik(); + self.direction_hint = 0; + self.direction_count = 0; + } + fn reset_ik(&mut self) { + self.ik_blend = 0.0; + self.ik_distance = -1.0; + self.ik_released = false; + self.ik_latched = false; + } + fn estimate_apex(&mut self) { + //82D63280 averages four authored time-warp samples. + let offset = self.warped - self.apex; + let stride = (offset * f32::from_bits(0x3eaaaaab)).abs(); //Globals440 + let mean = (0..4) + .map(|i| 1.0 + self.settings.time_warp.evaluate(offset + i as f32 * stride)) + .sum::() + * 0.25; + if mean != 0.0 { + self.estimated_phase = (-1.0 / mean) * offset; + } + self.phase = self.estimated_phase; + } +} +pub(super) fn apex_time(t: Trajectory) -> f32 { + if t.velocity[1] < 0.0 || t.acceleration[1] >= 0.0 { + 0.0 + } else { + t.velocity[1] * reciprocal(-t.acceleration[1]) + } +} + +///Ground82D37F38 consumes the candidate query before its shared skeleton update. +pub(super) fn ground_update( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let p = &skater.player_input.processed; + if p.flags_2476 & (1 << 22) == 0 { + skater.handplant.full_reset(); + return Ok(()); + } + if let Some((candidate, com, velocity, normal, heading)) = skater.handplant.pending.take() { + let surface = skate_core::air::trajectory::grind_surface::investigate( + &physics.world, + candidate.edge.start, + candidate.edge.end, + candidate.point, + skater.handplant.settings.truck_distance, + )?; + bevy::log::info!("HANDPLANT_SURFACE tick={} kind={:?} point={:?}", + physics.ticks, surface.as_ref().map(|s| s.evidence.kind), candidate.point); + if surface.is_some_and(|s| s.evidence.kind != 3) { + skater.handplant.launch( + candidate, + com, + velocity, + normal, + heading, + physics.riding.reckoning_frames.heading, + ); + } + } + if skater.handplant.flags & 0x8000_0000 != 0 { + ik::update(skater, true); + } + Ok(()) +} + +/// Capture the solved pose before the bail check, rather than comparing this +/// tick's targets against last tick's bodies in HANDPLANT_POSE. +pub(super) fn trace_solved(physics: &GamePhysics, skater: &SkaterRuntime) { + if physics.ticks % 6 != 0 && skater.collision_maximum_error.unwrap_or(0.0) < 0.15 { + return; + } + let root = &skater.animated_skeleton.roots.animation_to_world; + let actual = skater.skeleton.part_transforms(); + let parts = [3, 4, 5, 6, 7, 8, 9, 10]; + let authored = parts.map(|i| point(root, skater.animated_skeleton.record.pose[i][3])); + let targets = parts.map(|i| point(root, skater.skeleton_input.drive_frames[i][3])); + let solved = parts.map(|i| actual[i][3]); + let strengths = parts.map(|i| skater.skeleton_drives.bones[i].as_ref() + .map(|bone| (bone.active, bone.dynamics.strengths))); + let ik = &skater.foot_ik.state.limbs; + let feedback = &skater.collision_feedback; + bevy::log::info!("HANDPLANT_SOLVED tick={} phase={} parts={parts:?} authored={authored:?} targets={targets:?} solved={solved:?} strengths={strengths:?} ik={ik:?} partial={} collision_weight={} pose_errors={:?} anchor={:?}", + physics.ticks, skater.handplant.phase, skater.skeleton_collision.partial_ragdoll, + feedback.drive_weight, skater.pose_errors.parts, skater.handplant.anchor); +} +///Ground82D38430 submits the candidate before82D37F38 consumes its investigation. +pub(super) fn ground_query(physics: &GamePhysics, skater: &mut SkaterRuntime) { + let p = &skater.player_input.processed; + if p.flags_2476 & (1 << 22) == 0 { + skater.handplant.full_reset(); + return; + } + let h = &mut skater.handplant; + let com = p.vectors_544_560_592_608[2].map(f32::from_bits); + let velocity = p.vectors_400_416[0].map(f32::from_bits); + let normal = p.vectors_464_480_496_512_528[0].map(f32::from_bits); + let heading = p.vectors_544_560_592_608[1].map(f32::from_bits); + if p.flags_2480 & (1 << 29) != 0 || p.flags_2480 & (1 << 28) == 0 { + h.direction_hint = 0; + h.direction_count = 0; + } else if normal[1] < 0.95 { + if h.direction_hint == 0 { + h.direction_hint = h.candidate.map_or(0, |c| c.side); + } + let side = if dot(velocity, normalize(cross(UP, normal))) > 0.0 { + 1 + } else { + 2 + }; + if h.direction_hint == side { + h.direction_count = 0; + } else { + h.direction_count += 1; + if h.direction_count > h.settings.direction_frames { + h.direction_hint = 0; + } + } + } else { + h.direction_hint = 0; + h.direction_count = 0; + } + let candidate = contact::select( + &h.settings, + com, + velocity, + normal, + h.direction_hint, + &physics.grind.primitives, + ); + if candidate.is_some() || physics.ticks % 30 == 0 { + bevy::log::info!("HANDPLANT_QUERY tick={} speed={} vy={} normal={:?} minimum_speed={} minimum_slope={} edges={} candidate={:?} prior_flags={:08x} phase={} processed={:08x}/{:08x}", + physics.ticks, length(velocity), velocity[1], normal, h.settings.minimum_speed, + h.settings.minimum_slope, physics.grind.primitives.len(), candidate.map(|c| c.point), + h.flags, h.phase, p.flags_2476, p.flags_2480); + } + //82D61268 clears the active output when submission begins. + let old_point = h.candidate.map_or([0.0; 4], |c| c.point); + let hint = h.direction_hint; + let count = h.direction_count; + h.reset(); + h.previous_candidate_point = old_point; + h.direction_hint = hint; + h.direction_count = count; + h.candidate = candidate; + h.pending = candidate.map(|c| (c, com, velocity, normal, heading)); +} +pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut skater.ground_lifecycle.board_animated_290); + let p = &skater.player_input.processed; + let h = &mut skater.handplant; + h.entry = Trajectory { + position: p.vectors_544_560_592_608[2].map(f32::from_bits), + velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + acceleration: GRAVITY, + duration: -1.0, + }; + h.warped = 0.0; + h.elapsed = 0.0; + h.continuation = true; + h.select_outgoing(&physics.world)?; + Ok(()) +} +pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + //82D4C3D8 ->82D91298: keep planted hands out of collision response. + skater.skeleton_collision.disable_handplant_contacts(2); + let h = &mut skater.handplant; + h.warped += (1.0 + h.settings.time_warp.evaluate(h.warped - h.apex)) * STEP; + h.estimate_apex(); + h.elapsed += STEP; + let pose = skater.animated_skeleton.record.pose.map(|local| { + skate_core::physics::skeleton_animation_record::compose_affine( + &skater.animated_skeleton.roots.animation_to_world, + &local, + ) + }); + let (com, up, heading) = h.values( + &pose, + skater.player_input.processed.vectors_544_560_592_608[2].map(f32::from_bits), + ); + skater.air_reckoning.update_plant( + &mut physics.riding, + &skater.player_input.processed, + up, + heading, + ); + ik::update(skater, false); + plant_skeleton::advance(physics, skater, com, None)?; + if physics.ticks % 6 == 0 || skater.handplant.elapsed <= STEP * 1.5 { + let root = &skater.animated_skeleton.roots.animation_to_world; + let parts = [23, 1, 3, 7, 15, 19]; //hips, head, hands, toes + let targets = parts.map(|i| point(root, skater.skeleton_input.drive_frames[i][3])); + let actual = skater.skeleton.part_transforms(); + let actual = parts.map(|i| actual[i][3]); + let hands = [2, 3].map(|i| (skater.foot_ik.state.external_targets[i].world_position, + skater.foot_ik.state.limbs[i].target_blend)); + bevy::log::info!("HANDPLANT_POSE tick={} elapsed={} phase={} com={com:?} up={up:?} heading={heading:?} root={root:?} targets={targets:?} actual={actual:?} hands={hands:?} force_mode={} flags={:08x}/{:08x}/{:08x}", + physics.ticks, skater.handplant.elapsed, skater.handplant.phase, + skater.skeleton_input.force_mode, skater.player_input.processed.flags_2468, + skater.player_input.processed.flags_2472, skater.player_input.processed.flags_2476); + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/handplant/contact.rs b/crates/skate-game/src/physics/handplant/contact.rs new file mode 100644 index 00000000..383cee75 --- /dev/null +++ b/crates/skate-game/src/physics/handplant/contact.rs @@ -0,0 +1,242 @@ +//! PositionSelector82D63D40, velocity window82D64420 and edge clipping82D651E0. +use super::{GRAVITY, Settings, UP, V, math::*}; +use skate_core::animation::foot_ik::transforms::inverse_rigid; +use skate_core::{ + air::trajectory::Trajectory, + physics::{grind_contact::Primitive, skeleton_animation_record::IDENTITY}, +}; + +#[derive(Clone, Copy)] +pub(super) struct Candidate { + pub point: V, + pub edge: Primitive, + pub side: i32, +} + +pub(super) fn select( + settings: &Settings, + com: V, + velocity: V, + normal: V, + hint: i32, + edges: &[Primitive], +) -> Option { + //82D61D88: ascending, above minimum speed, slope over45deg and |Ny|<=.95. + if !(length(velocity) > settings.minimum_speed + && velocity[1] > 0.0 + && normal[1].abs() <= 0.95 + && skate_core::trigonometry::acos(normal[1].clamp(-1.0, 1.0)) * f32::from_bits(0x42652ee1) + > settings.minimum_slope) + { + return None; + } + let mut frame = IDENTITY; + if normal[1].abs() <= 0.99 { + frame[0] = normalize(cross(UP, normal)); + frame[1] = UP; + frame[2] = cross(frame[0], UP); + } + frame[3] = com; + let inverse = inverse_rigid(&frame); + let mut velocity = rotate(&inverse, velocity); + velocity = [ + velocity[0], + (velocity[0] * velocity[0] + velocity[1] * velocity[1]).sqrt(), + 0.0, + 0.0, + ]; + if hint != 0 && hint != if velocity[0] > 0.0 { 1 } else { 2 } { + velocity[0] = if hint == 1 { 1.0 } else { -1.0 } * f32::from_bits(0x37800000); + } + let g = settings.window.each_ref().map(|g| g.evaluate(velocity[1])); + let lo_x = g[1].max(velocity[0].abs() - g[5]); + let hi_x = g[6].min(velocity[0].abs() + g[4]); + if hi_x <= lo_x { + return None; + } + let lo_y = g[0].max(velocity[1] - g[3]); + let hi_y = velocity[1] + g[2]; + let sign = if velocity[0] > 0.0 { 1.0 } else { -1.0 }; + let apex = |x, y| { + let trajectory = Trajectory { + position: point(&inverse, com), + velocity: [x * sign, y, 0.0, 0.0], + acceleration: GRAVITY, + duration: -1.0, + }; + let mut p = trajectory.position_at(super::apex_time(trajectory)); + p[1] -= settings.window_drop; + p + }; + //Native order:r5 lowX/lowY,r6 highX/lowY,r7 lowX/highY,r8 highX/highY. + let [a, b, c, d] = [ + apex(lo_x, lo_y), + apex(hi_x, lo_y), + apex(lo_x, hi_y), + apex(hi_x, hi_y), + ]; + let polygon = if d[0] > c[0] { + [c, d, b, a] + } else { + [d, c, a, b] + }; + //82C1EAD8 preserves world order and caps the broadphase result at40. + let mut candidates = Vec::new(); + for edge in edges + .iter() + .filter(|e| { + (0..3).all(|i| { + let extent = [2.0, 4.0, 2.0][i]; + e.start[i].min(e.end[i]) <= com[i] + extent + && e.start[i].max(e.end[i]) >= com[i] - extent + }) + }) + .take(40) + { + let start = point(&inverse, edge.start); + let end = point(&inverse, edge.end); + if normalize(sub(end, start))[0].abs() <= 0.71 { + continue; + } //82D64218 + let mut segment = [start, end]; + //82D657D0/658A0 retains -depth segment[1][1] { + segment[0] + } else { + segment[1] + }; + if p[1] > height + 0.1 || (p[1] > height - 0.1 && p[1] <= height + 0.1 && p[2] > depth) { + best = Some(i); + height = p[1]; + depth = p[2]; + } + } + let best = best?; + let mut connected = vec![false; candidates.len()]; + connected[best] = true; + //82D65BD8: extend both ends across neighbours within .05m and30degrees. + for reverse in [false, true] { + let mut ends = candidates[best].1; + if reverse { + ends.swap(0, 1); + } + loop { + let next = candidates.iter().enumerate().find_map(|(i, (_, s))| { + if connected[i] { + return None; + } + for e in 0..2 { + if dot(sub(ends[1], s[e]), sub(ends[1], s[e])) < f32::from_bits(0x3b23d70a) + && skate_core::trigonometry::acos( + dot( + normalize(sub(ends[1], ends[0])), + normalize(sub(s[1 - e], s[e])), + ) + .clamp(-1.0, 1.0), + ) + .abs() + < f32::from_bits(0x3f060a92) + { + return Some((i, [s[e], s[1 - e]])); + } + } + None + }); + let Some((i, s)) = next else { break }; + connected[i] = true; + ends = s; + } + } + let bottom = scale(add(polygon[2], polygon[3]), 0.5); + let top = scale(add(polygon[0], polygon[1]), 0.5); + let delta = sub(top, bottom); + let target = madd( + delta, + clamp01((height - bottom[1]) * reciprocal(delta[1])), + bottom, + ); + let target = [target[0], target[1], 0.0, 0.0]; + let mut nearest = [0.0; 4]; + let mut distance = f32::MAX; + for (i, (_, segment)) in candidates.iter().enumerate() { + if !connected[i] { + continue; + } + let delta = sub(segment[1], segment[0]); + let square = dot(delta, delta); + let p = if square > f32::from_bits(0x37800000) { + madd( + delta, + clamp01(dot(sub(target, segment[0]), delta) * reciprocal(square)), + segment[0], + ) + } else { + segment[0] + }; + let square = dot(sub(p, target), sub(p, target)); + if square < distance { + distance = square; + nearest = p; + } + } + Some(Candidate { + point: point(&frame, nearest), + edge: candidates[best].0, + side: if velocity[0] > 0.0 { 1 } else { 2 }, + }) +} +//82D658A0 excludes coplanar segments and avoids dividing near-parallel Z. +fn clip_depth(segment: &mut [V; 2], value: f32, above: bool) -> bool { + let sign = if above { 1.0 } else { -1.0 }; + let a = (segment[0][2] - value) * sign; + let b = (segment[1][2] - value) * sign; + if a <= 0.0 && b <= 0.0 { + return false; + } + let delta = segment[1][2] - segment[0][2]; + if delta.abs() >= f32::from_bits(0x37800000) { + let t = (value - segment[0][2]) * reciprocal(delta); + if t > 0.0 && t < 1.0 { + let p = madd(sub(segment[1], segment[0]), t, segment[0]); + segment[usize::from(b <= 0.0)] = p; + } + } + true +} +fn clip(segment: &mut [V; 2], a: f32, b: f32) -> bool { + if a < 0.0 && b < 0.0 { + return false; + } + if (a < 0.0) != (b < 0.0) { + let p = madd( + sub(segment[1], segment[0]), + a * reciprocal(a - b), + segment[0], + ); + segment[usize::from(a >= 0.0)] = p; + } + true +} diff --git a/crates/skate-game/src/physics/handplant/ik.rs b/crates/skate-game/src/physics/handplant/ik.rs new file mode 100644 index 00000000..56e9574c --- /dev/null +++ b/crates/skate-game/src/physics/handplant/ik.rs @@ -0,0 +1,90 @@ +//! HandPlantManager82D633B0 and Skeleton82BD9728/97D0, shared four-limb IK. +use super::*; +pub(super) fn update(skater: &mut SkaterRuntime, ground: bool) { + let p = &skater.player_input.processed; + let h = &mut skater.handplant; + if ground && (p.flags_2480 & (1 << 29) != 0 || p.flags_2480 & (1 << 28) == 0) { + h.reset_ik(); + } + let right = (h.flags & 0x2000_0000 != 0) ^ (p.flags_2476 & (1 << 2) != 0); + let side = usize::from(right); + let limb = 2 + side; + let animated = &skater.animated_skeleton; + let world = |part: usize| { + point( + &animated.roots.animation_to_world, + animated.record.pose[part][3], + ) + }; + let mut hand = world(3 + side * 4); + let shoulder = world(6 + side * 4); + let attached = h.phase > -h.settings.hand_release; + if !attached { + h.ik_released = true; + } + let shoulder_delta = sub(shoulder, h.anchor); + if !h.ik_released && length(shoulder_delta) > f32::from_bits(0x37800000) { + let axis = normalize(shoulder_delta); + let projection = dot(sub(hand, h.anchor), axis); + if projection <= 0.0 { + h.ik_released = true; + } else { + let on_axis = madd(axis, projection, h.anchor); + if h.ik_latched { + hand = on_axis; + } else { + let radial = sub(hand, on_axis); + let radius = h.settings.hand_radius.evaluate(projection); + if length(radial) > radius { + hand = madd(normalize(radial), radius, on_axis); + } + } + } + let delta = sub(hand, h.anchor); + let distance = length(delta); + if h.ik_distance < 0.0 { + h.ik_distance = distance; + } + h.ik_distance = distance.min(h.ik_distance - h.settings.hand_approach); + if h.ik_distance < f32::from_bits(0x37800000) || distance < f32::from_bits(0x37800000) { + h.ik_released = true; + } else { + hand = madd(normalize(delta), h.ik_distance, h.anchor); + } + if !h.ik_released && !h.ik_latched { + let on_axis = madd(axis, dot(sub(hand, h.anchor), axis), h.anchor); + if length(sub(hand, on_axis)) < 0.02 { + h.ik_latched = true; + } + } + } + if h.ik_released { + hand = h.anchor; + } + let delta = sub(hand, shoulder); + if length(delta) > 0.65 { + hand = madd(normalize(delta), 0.65, shoulder); + } + h.ik_blend = clamp01( + h.ik_blend + + if attached { + STEP / h.settings.hand_into + } else { + -STEP / h.settings.hand_out + }, + ); + if h.ik_blend > 0.0 { + //82BD9728 clamps against the authored reparented hand target. + let original = point( + &animated.roots.animation_to_world, + animated.targets[limb][3], + ); + let delta = sub(hand, original); + if length(delta) > 0.65 { + hand = madd(normalize(delta), 0.65, original); + } + skater.foot_ik.state.external_targets[limb].world_position = hand; + skater.foot_ik.state.limbs[limb].external_target_set = true; + skater.foot_ik.state.limbs[limb].target_blend = h.ik_blend; + } +} diff --git a/crates/skate-game/src/physics/handplant/rotation.rs b/crates/skate-game/src/physics/handplant/rotation.rs new file mode 100644 index 00000000..1baedd35 --- /dev/null +++ b/crates/skate-game/src/physics/handplant/rotation.rs @@ -0,0 +1,50 @@ +//! CreateMtxFromHeadingAndUp82D61B60 and axis-angle frame blend82BD3150. +use super::*; +use skate_core::animation::foot_ik::transforms::inverse_rigid; +use skate_core::math::Basis3; +use skate_core::physics::{ + drive_frames::retail_quaternion_from_basis, skeleton_animation_record::compose_affine, +}; +pub(super) fn frame(mut heading: V, up: V) -> [V; 4] { + if dot(heading, up).abs() > f32::from_bits(0x3f7fff58) { + heading = [1.0, 0.0, 0.0, 0.0]; + if dot(heading, up).abs() > f32::from_bits(0x3f7fff58) { + heading = [0.0, 0.0, 1.0, 0.0]; + } + } + let right = normalize(cross(up, heading)); + let forward = normalize(cross(right, up)); + [right, normalize(cross(forward, right)), forward, [0.0; 4]] +} +pub(super) fn blend(a: [V; 4], b: [V; 4], weight: f32) -> [V; 4] { + if weight >= 1.0 { + return b; + } + if weight <= 0.0 { + return a; + } + let relative = compose_affine(&b, &inverse_rigid(&a)); + let q = retail_quaternion_from_basis(Basis3 { + columns: std::array::from_fn(|i| relative[i][..3].try_into().unwrap()), + }); + let sign = if q.w < 0.0 { -1.0 } else { 1.0 }; + let angle = 2.0 * skate_core::trigonometry::acos((q.w * sign).clamp(-1.0, 1.0)); + let mut result = a; + if angle < f32::from_bits(0x3d0efa35) { + for i in 0..3 { + result[i] = normalize(madd(sub(b[i], a[i]), weight, a[i])); + } + } else { + let axis = normalize([q.x * sign, q.y * sign, q.z * sign, 0.0]); + let (sin, cos) = skate_core::trigonometry::sin_cos(angle * weight); + for i in 0..3 { + result[i] = madd( + axis, + dot(axis, a[i]) * (1.0 - cos), + madd(cross(axis, a[i]), sin, scale(a[i], cos)), + ); + } + } + result[3] = madd(sub(b[3], a[3]), weight, a[3]); + result +} diff --git a/crates/skate-game/src/physics/handplant/settings.rs b/crates/skate-game/src/physics/handplant/settings.rs new file mode 100644 index 00000000..06cafa90 --- /dev/null +++ b/crates/skate-game/src/physics/handplant/settings.rs @@ -0,0 +1,96 @@ +//! TU3 cached layouts55B5940075F78842 and0F38084037CB9175. +use skate_core::point_graph::PointGraph; +use skate_data::collections::Collections; + +pub(super) struct Settings { + pub window: [PointGraph<4>; 7], + pub depth: f32, + pub window_drop: f32, + pub time_warp: PointGraph<8>, + pub out_heading: PointGraph<8>, + pub into_rotation: PointGraph<8>, + pub out_rotation: PointGraph<8>, + pub hand_radius: PointGraph<4>, + pub curve_half_time: f32, + pub entry_blend: f32, + pub hand_out: f32, + pub hand_into: f32, + pub hand_release: f32, + pub minimum_speed: f32, + pub minimum_slope: f32, + pub rotation_time: f32, + pub committed_time: f32, + pub minimum_out_speed: f32, + pub hand_approach: f32, + pub direction_frames: i32, + pub apex_radius: f32, + pub apex_angle: f32, + pub animation: [f32; 3], + pub truck_distance: f32, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let root = "Hash_55B5940075F78842"; + let graph4 = |class, name| -> Result, String> { + let w = data + .words::<12>(class, "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: w[4..8].try_into().unwrap(), + y: w[8..12].try_into().unwrap(), + }) + }; + let graph8 = |name| -> Result, String> { + let w = data + .words::<20>("physics_handplantmanager", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: w[4..12].try_into().unwrap(), + y: w[12..20].try_into().unwrap(), + }) + }; + let f = |name| data.float("physics_handplantmanager", "default", name); + Ok(Self { + truck_distance: data.float("physics_grinds", "default", "DeckCenterToTruck")?, + window: [ + graph4(root, "Hash_FF0DE8A42210E8C0")?, + graph4(root, "Hash_B90C78786ED04A7D")?, + graph4(root, "Hash_DC7947C6DD03CDAA")?, + graph4(root, "Hash_0ACB7AAE2662FBB2")?, + graph4(root, "Hash_CF7F668AB995380D")?, + graph4(root, "Hash_8348DA199CC87B7A")?, + graph4(root, "Hash_F27F622CAE33BD50")?, + ], + depth: data.float(root, "default", "Hash_3D9138A04C6D43F6")?, + window_drop: data.float(root, "default", "Hash_A046D0516C0BD62B")?, + time_warp: graph8("Hash_F551DF3289124FB0")?, + out_heading: graph8("Hash_8FC9A4F9002CF9E0")?, + into_rotation: graph8("Hash_F61B0FB73C39311B")?, + out_rotation: graph8("Hash_D4EF764AF4D5F1CA")?, + hand_radius: graph4("physics_handplantmanager", "Hash_5494E3F9BE572D6B")?, + curve_half_time: f("Hash_2460D6544C3B64B7")?, + entry_blend: f("Hash_CCD7A82CE1BBFFF2")?, + hand_out: f("Hash_E0895C189D77CE16")?, + hand_into: f("Hash_01EB855A0ACBD84C")?, + hand_release: f("Hash_9C02B9E6998D1321")?, + minimum_speed: f("Hash_63303CA6C611DC79")?, + minimum_slope: f("Hash_D1622917F6DD215F")?, + rotation_time: f("Hash_D595A32C5F02178F")?, + committed_time: f("Hash_B5EC5A0941B407A4")?, + minimum_out_speed: f("Hash_49C91803479B9BED")?, + hand_approach: f("Hash_7C8B87F7435221C4")?, + direction_frames: data.words::<1>( + "physics_handplantmanager", + "default", + "Hash_DDE5BFBCB6A1AFD5", + )?[0] as i32, + apex_radius: f("Hash_A046D0516C0BD62B")?, + apex_angle: f("Hash_90CDD541098AB4B5")?, + animation: [ + data.float("anim_handplant", "default", "Hash_B9012B86EB5258FE")?, + data.float("anim_handplant", "default", "Hash_512342FB1D4A7D57")?, + data.float("anim_handplant", "default", "Hash_673F3B1BD056C39E")?, + ], + }) + } +} diff --git a/crates/skate-game/src/physics/handplant/trajectory.rs b/crates/skate-game/src/physics/handplant/trajectory.rs new file mode 100644 index 00000000..961fbebf --- /dev/null +++ b/crates/skate-game/src/physics/handplant/trajectory.rs @@ -0,0 +1,312 @@ +//! Handplant Launch82D613B0, trajectory selector82D66318/66948 and COM82D625C0. +use super::*; +use skate_core::{air::trajectory::QueryRequest, physics::board_world::BoardWorld}; + +impl Handplant { + pub(super) fn launch( + &mut self, + c: contact::Candidate, + com: V, + velocity: V, + normal: V, + body_heading: V, + heading: V, + ) { + let mut side = normalize(cross(sub(c.edge.end, c.edge.start), UP)); + if dot(side, normal) > 0.0 { + side = scale(side, -1.0); + } + self.direction = scale(side, -1.0); + let apex = apex_position(c.point, side, self.settings.apex_radius, self.settings.apex_angle); + self.initial = to_apex(com, apex); + self.apex = apex_time(self.initial); + self.warped = 0.0; + self.outgoing = [self.initial; 2]; + self.travel_sign = if dot(heading, velocity) > 0.0 { + 1.0 + } else { + -1.0 + }; + //830BD4A0=-1 and830BD320=1; signs follow native vsel order. + self.rotations[0] = rotation::frame(heading, normal); + self.rotations[0][3] = com; + let mut along = normalize(sub(c.edge.start, c.edge.end)); + let direction = if dot([along[0], 0.0, along[2], 0.0], sub(apex, com)) > 0.0 { + 1.0 + } else { + -1.0 + }; + along = scale(along, direction * self.travel_sign); + self.rotations[1] = rotation::frame(along, normalize(sub(c.point, apex))); + self.rotations[1][3] = apex; + self.rotations[2] = rotation::frame(scale(UP, -self.travel_sign), normal); + self.rotations[3] = self.rotations[2]; + self.estimate_apex(); + let changed = length(sub(self.previous_candidate_point, c.point)) > 0.15; + let front = dot(sub(c.point, com), body_heading) < 0.0; + self.flags = (self.flags & 0x0fff_ffff) + | 0x8000_0000 + | if changed { 0x4000_0000 } else { 0 } + | if front { 0x2000_0000 } else { 0 } + | if self.phase < self.settings.committed_time { + 0x1000_0000 + } else { + 0 + }; + self.anchor = c.point; + self.candidate = Some(c); + } + pub(super) fn select_outgoing(&mut self, world: &BoardWorld) -> Result<(), String> { + let candidate = self + .candidate + .ok_or("Handplant entry requires the selected coping")?; + let apex = self.initial.position_at(self.apex); + let incoming = self.initial.velocity_at(self.apex); + let across = scale(self.direction, dot(incoming, self.direction)); + let tangent = sub(incoming, across); + let along = normalize(tangent); + let speed = length(add(across, clamp_length(tangent, 2.0))); + if candidate.side != 0 { + let expected = scale( + cross(UP, self.direction), + if candidate.side == 1 { 1.0 } else { -1.0 }, + ); + self.continuation = dot(expected, along) >= 0.0; + } + let mut position = madd(self.direction, 0.1, self.anchor); + position[1] = apex[1]; + let mut selected = None; + let mut best_up = 1.1; + let mut final_trajectory = EMPTY; + for angle in [ + 0.0, + 0.24, + 0.48, + f32::from_bits(0x3f3851eb), + 0.96, + f32::from_bits(0x3f999999), + ] { + let (sin, cos) = skate_core::trigonometry::sin_cos(angle); + let velocity = scale(madd(self.direction, sin, scale(along, cos)), speed); + let trajectory = Trajectory { + position, + velocity, + acceleration: GRAVITY, + duration: 3.0, + }; + final_trajectory = trajectory; + let hit = super::super::air_trajectory::AirTrajectoryRuntime::query( + world, + QueryRequest { + trajectory, + radius: 0.2, + start_error: 1.0, + end_error: 1.0, + }, + )?; + if hit.contact_time < 0.0 { + continue; + } + let contact = hit.contact_position; //Admission uses GetPosition82D2D988. + if self.anchor[1] - contact[1] <= 0.5 { + continue; + } + let normal = hit.landing_normal; + //82D66B64..98/82D66BC0..BF8 evaluate the query trajectory at + //contact time for the landing anchor, not the surface contact. + let point = trajectory.position_at(hit.contact_time); + //The host sphere query can return a nearby ledge whose lifted + //landing target lies above the apex. It cannot define a descending + //arc (82D608A0 takes sqrt(2*g*drop)). Keep searching; if none is + //feasible, use the existing native three-second fallback below. + let destination = madd(normal, 0.56, point); + if !destination.iter().all(|v| v.is_finite()) || !(destination[1] < apex[1]) { + continue; + } + if normal[1] < 0.71 { + selected = Some((point, normal)); + break; + } + if normal[1] < best_up { + best_up = normal[1]; + selected = Some((point, normal)); + } + } + let (landing, normal) = selected.unwrap_or((final_trajectory.position_at(3.0), UP)); + let destination = madd(normal, 0.56, landing); + let mut outgoing = from_apex(apex, destination); + let fall_time = outgoing.duration; + //82D66D70 rebase the outgoing parabola to the incoming time origin. + outgoing.position = outgoing.position_at(-self.apex); + outgoing.velocity = outgoing.velocity_at(-self.apex); + outgoing.duration = -1.0; + self.outgoing = [outgoing; 2]; + let speed = dot(self.direction, self.outgoing[1].velocity); + if speed < self.settings.minimum_out_speed { + self.outgoing[1].velocity = madd( + self.direction, + self.settings.minimum_out_speed - speed, + self.outgoing[1].velocity, + ); + } + self.rotations[2][3] = outgoing.position_at(2.0 * self.apex); + let time = self.apex + fall_time; + self.rotations[3] = rotation::frame( + scale(normalize(outgoing.velocity_at(time)), self.travel_sign), + normal, + ); + self.rotations[3][3] = outgoing.position_at(time); + self.out_duration = (time - self.phase).max(0.5); + self.build_curve(); + Ok(()) + } + fn build_curve(&mut self) { + //82D62408: secant handles one fixed tick apart, scaled by6. + let start = self.apex - self.settings.curve_half_time; + let end = self.apex + self.settings.curve_half_time; + self.curve[0] = self.initial.position_at(start); + self.curve[1] = madd( + sub(self.initial.position_at(start + STEP), self.curve[0]), + 6.0, + self.curve[0], + ); + self.curve[3] = self.outgoing[1].position_at(end); + self.curve[2] = madd( + sub(self.outgoing[1].position_at(end - STEP), self.curve[3]), + 6.0, + self.curve[3], + ); + } + pub(super) fn values(&mut self, pose: &[[V; 4]], physical_com: V) -> (V, V, V) { + let relative = self.warped - self.apex; + let fraction = (1.0 + relative * reciprocal(self.settings.curve_half_time)) * 0.5; + let mut com = if fraction < 0.0 { + self.initial.position_at(self.warped) + } else if fraction > 1.0 { + let blend = + clamp01((relative - self.settings.curve_half_time) * reciprocal(self.out_duration)); + madd( + self.outgoing[0].position_at(self.warped), + blend, + scale(self.outgoing[1].position_at(self.warped), 1.0 - blend), + ) + } else { + bezier(self.curve, fraction) + }; + let foot_y = pose[15][3][1].max(pose[19][3][1]); + let (divisor, offset) = if self.flags & 0x2000_0000 != 0 { + (5.0, 0.0) + } else { + (3.2, -0.05) + }; + com[1] += ((foot_y - physical_com[1]) / divisor + offset).max(0.0); + let blend = (1.0 - self.elapsed * reciprocal(self.settings.entry_blend)).max(0.0); + let blended = madd( + com, + 1.0 - blend, + scale(self.entry.position_at(self.elapsed), blend), + ); + if blend > 0.0 && fraction < 0.0 { + let velocity = self.initial.velocity_at(self.warped); + let correction = + dot(sub(blended, com), normalize(velocity)) * reciprocal(length(velocity)); + self.warped += correction.clamp(f32::from_bits(0xbc088889), f32::from_bits(0x3c088889)); + } + let frame = if self.warped < self.apex { + let fraction = (1.0 + - (self.apex - self.warped) + * reciprocal(self.apex.min(self.settings.rotation_time))) + .max(0.0); + rotation::blend( + self.rotations[0], + self.rotations[1], + self.settings.into_rotation.evaluate(fraction), + ) + } else { + let fraction = self.warped - self.apex; + let out = self + .settings + .out_heading + .evaluate(clamp01(fraction * reciprocal(self.out_duration))); + let into = self + .settings + .out_rotation + .evaluate(clamp01(fraction * reciprocal(self.settings.rotation_time))); + rotation::blend( + self.rotations[1], + rotation::blend(self.rotations[2], self.rotations[3], out), + into, + ) + }; + (blended, frame[1], frame[2]) + } +} +///82D61458/64 call standalone Cos/Sin. Permute mask822FBAC0 followed +///by vsldoi8 produces[sin,cos,0,0]: sine goes sideways, cosine goes up. +fn apex_position(coping: V, side: V, radius: f32, angle: f32) -> V { + let sin = skate_core::trigonometry::sin(angle); + let cos = skate_core::trigonometry::cos(angle); + madd(side, radius * sin, madd(UP, radius * cos, coping)) +} +fn to_apex(start: V, end: V) -> Trajectory { + let t = ((-2.0 * GRAVITY[1]) * (end[1] - start[1])).sqrt() * reciprocal(-GRAVITY[1]); + arc_between(start, end, t) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn stock_handplant_apex_is_above_coping_and_back_towards_ramp() { + let coping = [-7.0, 3.8850045, 5.5987973, 0.0]; + let apex = apex_position(coping, [0.0,0.0,1.0,0.0], 0.8, -0.3); + assert!((apex[1]-4.649274).abs()<0.00001); + assert!((apex[2]-5.362381).abs()<0.00001); + //Observed late attempt COM previously exceeded the incorrectly lowered + //apex. The original geometry admits a finite ascending trajectory. + let start = [-6.9291053,3.925457,4.9562364,0.0]; + let trajectory = to_apex(start,apex); + assert!(trajectory.velocity.iter().all(|v|v.is_finite())); + assert!(trajectory.duration>0.0); + let end=trajectory.position_at(trajectory.duration); + for i in 0..3 { assert!((end[i]-apex[i]).abs()<0.00001); } + assert!(trajectory.velocity_at(trajectory.duration)[1].abs()<0.00001); + } + #[test] + fn apex_angle_zero_places_com_directly_above_lip_for_either_edge_direction() { + for direction in [-1.0,1.0] { + let apex=apex_position([2.0,3.0,4.0,0.0],[direction,0.0,0.0,0.0],0.8,0.0); + assert!((apex[1]-3.8).abs()<0.00001); + assert!((apex[0]-2.0).abs()<0.00001); + assert!((apex[2]-4.0).abs()<0.00001); + } + } +} +fn from_apex(start: V, end: V) -> Trajectory { + let t = ((-2.0 * GRAVITY[1]) * (start[1] - end[1])).sqrt() * reciprocal(-GRAVITY[1]); + arc_between(start, end, t) +} +fn arc_between(start: V, end: V, time: f32) -> Trajectory { + let velocity = scale( + sub(sub(end, start), scale(GRAVITY, 0.5 * time * time)), + reciprocal(time), + ); + Trajectory { + position: start, + velocity, + acceleration: GRAVITY, + duration: time, + } +} +fn bezier(p: [V; 4], t: f32) -> V { + let s = 1.0 - t; + madd( + p[3], + t * t * t, + madd( + p[2], + 3.0 * t * t * s, + madd(p[0], s * s * s, scale(p[1], 3.0 * t * s * s)), + ), + ) +} diff --git a/crates/skate-game/src/physics/input_phase.rs b/crates/skate-game/src/physics/input_phase.rs index ac7788ee..bee5e8f1 100644 --- a/crates/skate-game/src/physics/input_phase.rs +++ b/crates/skate-game/src/physics/input_phase.rs @@ -58,6 +58,8 @@ pub(super) fn advance( skeleton_input: &mut skater.skeleton_input, animated: &mut skater.animated_skeleton, skeleton_air: &mut skater.skeleton_air, + footplant: &mut skater.footplant, + handplant: &mut skater.handplant, body: &mut skater.skeleton, drives: &mut skater.skeleton_drives, ik: &mut skater.foot_ik, @@ -147,6 +149,8 @@ struct Callbacks<'a, 'p> { skeleton_input: &'a mut SkeletonInputRuntime, animated: &'a mut AnimatedSkeleton, skeleton_air: &'a mut super::skeleton_air::SkeletonAir, + footplant: &'a mut super::footplant::Footplant, + handplant: &'a mut super::handplant::Handplant, body: &'a mut SkeletonBody, drives: &'a mut SkeletonDrives, ik: &'a mut FootIk, diff --git a/crates/skate-game/src/physics/input_teleport.rs b/crates/skate-game/src/physics/input_teleport.rs index 98982f55..a7ae5f94 100644 --- a/crates/skate-game/src/physics/input_teleport.rs +++ b/crates/skate-game/src/physics/input_teleport.rs @@ -44,6 +44,8 @@ impl Callbacks<'_, '_> { self.wiping_out, ); self.skeleton_air.reset_board(); //Board Reset82C0606C. + self.footplant.full_reset(); + self.handplant.full_reset(); self.riding.reset_for_teleport(); { let mut owners = SkeletonOwners { diff --git a/crates/skate-game/src/physics/known_air/runtime.rs b/crates/skate-game/src/physics/known_air/runtime.rs index ba882304..e7d20621 100644 --- a/crates/skate-game/src/physics/known_air/runtime.rs +++ b/crates/skate-game/src/physics/known_air/runtime.rs @@ -294,6 +294,7 @@ impl KnownAirRuntime for Runtime<'_> { body: &self.skater.skeleton, gravity: [g.x, g.y, g.z, 0.0], world: &self.physics.world, + edges: &self.physics.grind.primitives, }; self.skater.footplant.update_candidate(input, &f); } @@ -309,6 +310,7 @@ impl KnownAirRuntime for Runtime<'_> { body: &self.skater.skeleton, gravity: [g.x, g.y, g.z, 0.0], world: &self.physics.world, + edges: &self.physics.grind.primitives, }; let result = self.skater.footplant.consume_and_submit( input, diff --git a/crates/skate-game/src/physics/offboard/ground_state.rs b/crates/skate-game/src/physics/offboard/ground_state.rs index 16f74686..b2dbbb49 100644 --- a/crates/skate-game/src/physics/offboard/ground_state.rs +++ b/crates/skate-game/src/physics/offboard/ground_state.rs @@ -120,12 +120,6 @@ struct Services<'a> { skater: &'a mut SkaterRuntime, error: Option, } -impl Services<'_> { - fn missing(&mut self, name: &str) { - self.error - .get_or_insert_with(|| format!("Biped Sync requires {name}")); - } -} impl GroundQueryScene for Services<'_> { type Error = &'static str; fn edge_candidates( @@ -159,7 +153,10 @@ impl GroundQueryScene for Services<'_> { } impl ground_sync::Services for Services<'_> { type Launch = Result; - type Candidate = (); + //The host world currently loads static collision geometry, not native + //interactable object instances. Its object-candidate set is empty. Use an + //uninhabited candidate so a miss cannot accidentally bind a fabricated key. + type Candidate = std::convert::Infallible; fn processed(&self) -> ground_sync::Processed { let p = &self.skater.player_input.processed; ground_sync::Processed { @@ -205,12 +202,12 @@ impl ground_sync::Services for Services<'_> { fn board_flags_12836(&self) -> u8 { self.skater.ground_lifecycle.trajectory.flags_12836 } - fn probe_board(&mut self, _: Vector) -> Option<()> { - self.missing("world-object probe"); + fn probe_board(&mut self, _: Vector) -> Option { + //82D4D150 returns false when candidate count12752 is zero. None } - fn candidate_key_188(&self, _: &()) -> [u32; 2] { - unreachable!() + fn candidate_key_188(&self, candidate: &Self::Candidate) -> [u32; 2] { + match *candidate {} } fn bone_23_position(&mut self) -> Vector { skate_core::physics::skeleton_animation_record::compose_affine( @@ -220,15 +217,17 @@ impl ground_sync::Services for Services<'_> { } fn classify_board( &mut self, - _: &(), + candidate: &Self::Candidate, _: Vector, _: ground_sync::Bounds, _: ground_sync::BoardLimits, ) -> bool { - unreachable!() + match *candidate {} } fn query_board(&mut self, _: Vector, _: ground_sync::Bounds, _: ground_sync::BoardLimits) { - self.missing("world-object query"); + //An object query over this host's empty object population has no hits. + //Static line/edge queries still execute below through GroundQueryScene. + //Do not treat terrain triangles or the player's board as grabbed objects. } fn bind_board(&mut self, _: [u32; 2]) { unreachable!() @@ -237,7 +236,8 @@ impl ground_sync::Services for Services<'_> { unreachable!() } fn commit_free_board(&mut self, _: Frame) { - self.missing("world-object action"); + //No selected object means no object action to commit. Holding GrabWorld + //still reaches the authored graphs, skeleton, toolkit and edge queries. } fn reset_board(&mut self) { self.skater.ground_lifecycle.trajectory.cancel(); diff --git a/crates/skate-game/src/physics/plant_skeleton.rs b/crates/skate-game/src/physics/plant_skeleton.rs new file mode 100644 index 00000000..4e500cff --- /dev/null +++ b/crates/skate-game/src/physics/plant_skeleton.rs @@ -0,0 +1,91 @@ +//! Skeleton82BDEC48 (anchored foot) and82BDF090 (plant COM). +use super::{GamePhysics, SkaterRuntime, input_phase, skeleton_input_runtime::SkeletonOwners}; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, + board_animation::target_velocity, + skeleton_air_frames::update_plant_roots, + skeleton_animation_record::{IDENTITY, compose_affine}, + }, +}; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + anchor: [f32; 4], + bone: Option, +) -> Result<(), String> { + let collision = input_phase::collision(skater); + let p = &mut skater.player_input.processed; + let s = &mut skater.animated_skeleton; + let local = bone.map_or(s.record.centre_of_mass, |i| { + skater.skeleton_input.drive_frames[i][3] + }); + update_plant_roots( + &mut s.roots, + &physics.riding.reckoning_frames.system, + anchor, + local, + ); + s.motion.trajectory = IDENTITY; + s.motion.inverse_trajectory = IDENTITY; + let local_board = if bone.is_some() { + &s.record.pose[0] + } else { + &skater.skeleton_input.drive_frames[0] + }; + let target = compose_affine(&s.roots.animation_to_world, local_board); + p.flags_2468 |= 1 << 19; + s.board_frames.animation_target = target; + let effective = skater + .skeleton_air + .apply_board(&mut physics.board, &target, true); + s.board_frames.physical_board = effective; + s.board_frames.skate_root = effective; + if bone.is_none() { + let pos = physics.board.bodies()[BodyId::Deck.index()].rates.position; + let velocity = target_velocity(effective[3], [pos.x, pos.y, pos.z, 0.0], p.timestep_2604); + for body in physics.board.bodies_mut() { + body.rates.linear_velocity = Vector3::new(velocity[0], velocity[1], velocity[2]); + } + } + s.board_frames.update_com_lift( + &s.roots.animation_to_world, + s.board_frames.centre_of_mass, + 0.0, + ); + let mut owners = SkeletonOwners { + animated: s, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + skater.skeleton_input.general_update( + p, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + )?; + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(()) +} + +pub(super) fn hold_foot(skater: &mut SkaterRuntime, right: bool, position: [f32; 4], frames: u32) { + let side = usize::from(right); + skater.foot_ik.state.external_targets[side].world_position = position; + skater.foot_ik.state.limbs[side].external_target_set = true; + skater.foot_ik.state.limbs[side].target_blend = 1.0; + if frames != 0 { + for part in if right { [19, 20, 21] } else { [15, 16, 17] } { + skater.skeleton_collision.pending_reenable = true; + skater.skeleton_collision.disable_count[part] = frames; + skater.skeleton_collision.parts[part].enabled = false; + } + } +} diff --git a/crates/skate-game/src/physics/player_state/pre_state.rs b/crates/skate-game/src/physics/player_state/pre_state.rs index d2674b71..76c58600 100644 --- a/crates/skate-game/src/physics/player_state/pre_state.rs +++ b/crates/skate-game/src/physics/player_state/pre_state.rs @@ -15,8 +15,10 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific | PhysicalStateId::KnownAir - | PhysicalStateId::GroundAnimation + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant + | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir diff --git a/crates/skate-game/src/physics/player_state/publication.rs b/crates/skate-game/src/physics/player_state/publication.rs index e8185bd1..9a0f8a36 100644 --- a/crates/skate-game/src/physics/player_state/publication.rs +++ b/crates/skate-game/src/physics/player_state/publication.rs @@ -16,8 +16,10 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific | PhysicalStateId::KnownAir - | PhysicalStateId::GroundAnimation + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant + | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -57,10 +59,12 @@ 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); set(73, p.flags_2476 & (1 << 24) != 0); + set(74, state == PhysicalStateId::HandPlant && skater.handplant.continuation); set(75, state.category() == 500); set(76, state as u32 == 500); //82DB78A8..78DC: the processed off-board request refreshes three @@ -75,6 +79,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> set(77, dismount_requested); set(79, p.state_variant_index_2528 == 3); set(80, p.flags_2484 & (1 << 21) != 0); + set(83, skater.footplant.perform || skater.footplant.flag_627); if let Some(output) = &output { set(84, output.state_28.flag_84); set(86, output.state_28.has_world_grab_intent_without_object); @@ -87,6 +92,11 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> set(84, skater.slide_state.state.wall_riding); } let physical = &mut skater.player_input.physical; + physical.component_1832_word_1876=skater.handplant.flags; + physical.air.handplant_position_304=skater.handplant.anchor.map(f32::to_bits); + physical.air.handplant_time_320=skater.handplant.phase; + physical.air.flag_446=u8::from(skater.handplant.flags&0x8000_0000!=0); + skater.footplant.publish(&mut physical.air); if physics.grind.active { physical.grinds.words_136_140 = [physics.grind.kind, 1]; physical.grinds.flag_318 = u8::from(physics.grind.front_contact); @@ -120,6 +130,8 @@ 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. flag_69: u8::from(skater.player_state.selector.request_teleport), diff --git a/crates/skate-game/src/physics/player_state/transition.rs b/crates/skate-game/src/physics/player_state/transition.rs index 56d73399..63eb1298 100644 --- a/crates/skate-game/src/physics/player_state/transition.rs +++ b/crates/skate-game/src/physics/player_state/transition.rs @@ -18,8 +18,10 @@ impl PhysicalStateCalls for Calls { | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific | PhysicalStateId::KnownAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -34,8 +36,10 @@ impl PhysicalStateCalls for Calls { | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific | PhysicalStateId::KnownAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -62,8 +66,10 @@ pub(super) fn set( | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific | PhysicalStateId::KnownAir - | PhysicalStateId::GroundAnimation + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant + | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -74,8 +80,10 @@ pub(super) fn set( | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific | PhysicalStateId::KnownAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -157,6 +165,10 @@ 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 PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific => super::super::grind::exit(physics, skater)?, PhysicalStateId::BipedAir => super::super::offboard::air_state::exit(physics, skater), PhysicalStateId::BipedGround => super::super::offboard::ground_state::exit(physics, skater), @@ -172,6 +184,10 @@ 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), PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific => super::super::grind::enter(physics, skater), PhysicalStateId::BipedAir => super::super::offboard::air_state::enter(physics, skater), PhysicalStateId::BipedGround => { diff --git a/crates/skate-game/src/physics/revert_state.rs b/crates/skate-game/src/physics/revert_state.rs new file mode 100644 index 00000000..ac945dc9 --- /dev/null +++ b/crates/skate-game/src/physics/revert_state.rs @@ -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 { + 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] + ); + } +} diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 4bb016d5..f8573b50 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -42,9 +42,12 @@ 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, + pub boneless: super::boneless::Boneless, + pub handplant: super::handplant::Handplant, pub wipeout: super::wipeout::Wipeout, pub wipeout_state: super::wipeout_states::WipeoutState, pub respawn: super::respawn::Runtime, @@ -216,9 +219,12 @@ 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)?, + boneless: super::boneless::Boneless::load(&data)?, + handplant: super::handplant::Handplant::load(&data)?, wipeout: super::wipeout::Wipeout::load(&data)?, wipeout_state, teleport_state: super::teleport_state::Runtime::new(super::teleport_state::Checkpoint { diff --git a/crates/skate-game/src/physics/skeleton_colliders.rs b/crates/skate-game/src/physics/skeleton_colliders.rs index 4c4c67e2..0798c28c 100644 --- a/crates/skate-game/src/physics/skeleton_colliders.rs +++ b/crates/skate-game/src/physics/skeleton_colliders.rs @@ -16,6 +16,27 @@ use skate_core::{ pub(super) fn world_volumes( skeleton: &SkeletonBody, collision: &SkeletonCollisionMode, +) -> Result, String> { + let mut volumes = enabled_volumes(skeleton, collision)?; + retain_world_volumes(&mut volumes, collision); + Ok(volumes) +} + +/// Volume+84 group4 disables world contacts only. Keep these shapes available +/// to the separate SkaterSkaterCollisionPipeline and its own pair filters. +pub(super) fn retain_world_volumes( + volumes: &mut Vec, + collision: &SkeletonCollisionMode, +) { + volumes.retain(|volume| match volume.body { + CollisionBody::Attached(part) => collision.parts[part].volume_group != 4, + _ => unreachable!("skeleton volumes must reference attached bodies"), + }); +} + +pub(super) fn enabled_volumes( + skeleton: &SkeletonBody, + collision: &SkeletonCollisionMode, ) -> Result, String> { let transforms = skeleton.part_transforms(); let mut volumes = Vec::new(); @@ -29,7 +50,7 @@ pub(super) fn world_volumes( if !state.enabled { continue; } - if state.volume_group != 0 { + if !matches!(state.volume_group, 0 | 4) { return Err(format!( "Skater part{index} requires volume group{} world filtering", state.volume_group diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs index ea0567fb..da3797e3 100644 --- a/crates/skate-game/src/physics/solve.rs +++ b/crates/skate-game/src/physics/solve.rs @@ -1,6 +1,7 @@ //! One physical solve for the board, skater and original animation targets. //! The caller publishes forces and drive targets before entering this phase. mod assembly_contacts; +mod diagnostics; use super::{GamePhysics, SkaterRuntime, colliders, skeleton_colliders}; use skate_core::physics::{ board::BodyId, @@ -14,13 +15,22 @@ pub(super) fn advance( skater: &mut SkaterRuntime, truck_targets: [f32; 2], ) -> Result<(), String> { + let before = diagnostics::snapshot(physics, skater); + diagnostics::validate(&before, "before shared solve").map_err(|error| format!( + "{error}; com_frame={:?}; lifted_com_frame={:?}; animation_root={:?}; biped_position={:?}; biped_surface={:?}", + skater.animated_skeleton.board_frames.com_frame, + skater.animated_skeleton.board_frames.lifted_com_frame, + skater.animated_skeleton.roots.animation_to_world, + skater.offboard.controller.output().position, + skater.offboard.controller.state.surface, + ))?; let board_volumes = if skater.offboard.board_policy.volumes_enabled { colliders::world_volumes(&physics.board, &physics.settings) } else { Vec::new() }; let skeleton_volumes = - skeleton_colliders::world_volumes(&skater.skeleton, &skater.skeleton_collision)?; + skeleton_colliders::enabled_volumes(&skater.skeleton, &skater.skeleton_collision)?; // Each native assembly has its own query record and retention buffer. // Skeleton82BE5094 passes false to82768728: its edge threshold is -1, // whereas the board requests .999. GroundPipeline supplies the remaining @@ -34,8 +44,10 @@ pub(super) fn advance( let skeleton_timer = crate::performance::Scope::new("skeleton_world_contacts"); let mut skeleton_query = physics.query; skeleton_query.edge_cos_bend_normal_threshold = -1.0; + let mut skeleton_world_volumes = skeleton_volumes.clone(); + skeleton_colliders::retain_world_volumes(&mut skeleton_world_volumes, &skater.skeleton_collision); contacts.extend_from_slice(physics.world.query_primitives( - &skeleton_volumes, + &skeleton_world_volumes, skeleton_query, physics.retention, )); @@ -100,6 +112,11 @@ pub(super) fn advance( drives: &mut drives.rows, }, ); + if let Err(error) = diagnostics::validate( + &diagnostics::snapshot(physics, skater), "after shared solve", + ) { + return Err(format!("{error}; input_bodies={before:?}; contacts={contacts:?}; joints={joints:?}; drives={:?}", drives.rows)); + } skater .skeleton .publish_physical_record(deck_frame(&physics.board)); diff --git a/crates/skate-game/src/physics/solve/diagnostics.rs b/crates/skate-game/src/physics/solve/diagnostics.rs new file mode 100644 index 00000000..4231ed52 --- /dev/null +++ b/crates/skate-game/src/physics/solve/diagnostics.rs @@ -0,0 +1,35 @@ +//! Locate invalid physical values before they propagate into a rendered pose. +use super::{GamePhysics, SkaterRuntime}; +use skate_core::physics::assembly::BodySnapshot; + +pub(super) fn snapshot(physics: &GamePhysics, skater: &SkaterRuntime) -> Vec { + physics.board.bodies().iter() + .chain(std::iter::once(&physics.board.hook().body)) + .chain(skater.skeleton.bodies()) + .chain(skater.skeleton_drives.targets.bodies.iter()) + .copied().collect() +} + +pub(super) fn validate(bodies: &[BodySnapshot], stage: &str) -> Result<(), String> { + for (index, body) in bodies.iter().enumerate() { + let r = &body.rates; + for (name, v) in [ + ("position", r.position), + ("linear_velocity", r.linear_velocity), + ("angular_velocity", r.angular_velocity), + ("force_acceleration", r.force_acceleration), + ("torque_acceleration", r.torque_acceleration), + ] { + if ![v.x, v.y, v.z].into_iter().all(f32::is_finite) { + return Err(format!("Non-finite {name} {stage}; reaction_body={index}; body={body:?}")); + } + } + let q = r.orientation; + if ![q.x, q.y, q.z, q.w].into_iter().all(f32::is_finite) + || !r.basis.columns.iter().flatten().all(|v| v.is_finite()) + || !r.world_inverse_inertia.columns.iter().flatten().all(|v| v.is_finite()) { + return Err(format!("Non-finite orientation/inertia {stage}; reaction_body={index}; body={body:?}")); + } + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs index f935be96..0ae44ab3 100644 --- a/crates/skate-game/src/physics/wipeout.rs +++ b/crates/skate-game/src/physics/wipeout.rs @@ -94,6 +94,30 @@ pub(super) fn check_after_physics( grind_normal: skater.trajectory.selector.grind_normal(), }; match skater.player_state.current() { + PhysicalStateId::FootPlant | PhysicalStateId::HandPlant => { + let frame = observations.frame()?; + if skater.player_state.current() == PhysicalStateId::HandPlant { + super::handplant::trace_solved(physics, skater); + //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); + } + Ok(()) + } PhysicalStateId::BipedAir => { let mode = skater.wipeout.mode(&skater.player_input.processed)?; let frame = observations.frame()?; @@ -120,7 +144,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 => { diff --git a/docs/plants.md b/docs/plants.md new file mode 100644 index 00000000..acc56a6a --- /dev/null +++ b/docs/plants.md @@ -0,0 +1,257 @@ +# No-complys, footplants and handplants + +The stock graphs now reach physical states 602 (no-comply/boneless/fastplant), +601 (air footplant) and 600 (handplant). The implementation connects the TU3 +contact selectors, authored animation events, VLT curves, board drives, skeleton +roots, limb IK, release trajectories and state publication. `IsFootPlanting` is +registered in both graphs so its formerly unsupported leaf no longer pauses the +one-foot path. + +## Controller controls + +These are the existing Xbox mappings and stock graph transitions. Stance and +contact determine which animation and physical state can enter. + +| Action | Input | +| --- | --- | +| No-comply | Begin a front-foot push (X in regular, A in goofy), then flick the right stick for an ollie during push entry. The graph accepts the Square gesture group. | +| Boneless / fastplant | Hold one grab trigger fully, then press A or X. The chosen trigger, push foot and stance select the stock variant. | +| Air footplant | Ollie, hold LT or RT for a grab, then press and hold A or X for a one-foot air while approaching a reachable surface. Contact prediction and the extended foot determine entry. | +| Handplant | Hold RB while riding up a steep transition toward reachable coping. Move the right stick to tweak; A/X select one-foot variants and B selects the no-foot variant. | + +No-comply launches come from the clip's `FootJump` event. Footplants release at +the manager's calculated curve duration. Handplant entry requires upward travel, +the authored minimum speed/slope, a selected edge and a valid surface +investigation. Holding RB on flat ground does not force a handplant. + +## Local build + +Use the standard build and launch scripts with your prepared assets. The test +world supports no-complys and reachable footplant surfaces; handplants require +transition/coping geometry satisfying the native selector. Select a converted +map from the pause menu or supply it through `--map`. + +Every launcher run now saves both console output streams, build identity and the +exit code to a separate `logs/plants--.log`. Rust panic +backtraces are enabled. On failure the launcher displays the saved output and +keeps the console open. Earlier launcher runs did not save output, so their +simulation error messages cannot be recovered from this directory. + +The RMB/RB crash recorded at tick259 was an explicit Ground error on +`GrabWorld` (Processed2476 bit22). That older placeholder misidentified +`82D38430` as an unsupported Skitch query. The TU3 function actually submits +the handplant candidate via `82D61268`, or resets it via `82D62F20` when the +input is released. Those paths already run through `handplant::ground_query` +and `ground_update`. The obsolete error and its unused duplicate manager have +been removed; input still reaches the stock graphs and handplant selector. + +The subsequent normal-map run failed at tick4225 in KnownAir because enabled +foot part15 carried volume group4. This is the existing native +DisableCollisionWithWorld classification, not an unsupported shape. Enabled +group4 shapes now stay in the assembly collision pass but are excluded from +the world query. Re-enabling world collision by restoring group0 takes effect +on the next query. This fixes that explicit exit. + +Off-board GrabWorld no longer exits when the host has no interactable object +instances. The current static-world adapter returns an empty candidate result +(`82D4D150`'s zero-count path), cannot manufacture a candidate key, and commits +no object action. Authored input, static line/edge queries and skeleton updates +continue. Actual interactable-object loading, queries and actions still require +an object-scene adapter; this change does not implement those features. +The separately logged off-board non-finite follower velocity remains under +investigation, with source target values included in diagnostic builds. + +The handplant outgoing selector now distinguishes the surface contact used +for admission from the trajectory position at contact time used for the +landing anchor (`82D66B64..98`, `82D66BC0..BF8`). The host query adapter also +rejects lifted landing targets at or above the apex: those cannot produce a +descending `82D608A0` arc and previously reached its negative square root. +It continues the six authored queries and uses the existing three-second +fallback if none is feasible. This feasibility gate is a host query adaptation, +not a claim that the additional gate occurs in TU3. + +The tick2032 grind crash reached `IsDroppingIn` in the ActionGraph, which +previously registered the condition only in MotionGraph. Both now read the +same completed Grinds324 flag. Handplant attempts additionally log admission +inputs every half second while GrabWorld is held on ground, every selected +candidate and its surface classification, and relevant state transitions. +These traces diagnose rejected entries and bails; they do not establish that +handplants are gameplay-validated. + +The task executable is a separate copy, built with: + +```powershell +$env:CARGO_TARGET_DIR = Join-Path $PWD 'target' +$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static' +cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --no-default-features +``` + +The shared target is retained. Copy its `x86_64-pc-windows-msvc/release/skate3rust.exe` +to `bin/plants/skate3rust.exe` after rebuilding this branch. No installed or +running desktop executable is replaced. The launcher contains a machine-local +asset path; other installations must update that path. + +If another worktree has populated this shared target with different workspace +crates, refresh the modification times of `crates/skate-core/src/lib.rs`, +`crates/skate-data/src/lib.rs` and `crates/skate-game/src/main.rs` before building. +That forces the local crates to rebuild without deleting the dependency cache. + +## Native provenance + +Evidence is the owned TU3 executable image `default_82000000_011B0000.bin`, base +`82000000`, SHA256 +`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`, generated PPC +instruction listings, stock OnBoard graphs, OnBoard.abin, and skaterschema/VLT. +Registration strings and vtables identify the graph leaves. VMX128 instruction +operands take precedence over unreliable decompiler output and community names. + +| Behavior | TU3 evidence | +| --- | --- | +| Input | `8259A554/8259AF60` emits GrabWorld from packed bit28; `8259BB18` emits five handplant controls, named by constructors `82F84F28..82F84F98`. Existing A/X pushes feed the authored no-comply and one-foot paths. | +| No-comply / boneless | State602 vtable `82327434`; entry `82D4C900`, update `82D4C9B8`, launch `82D4CB18/82D4CC18`. Anchors the completed physical toe, uses the stock linear/angular drive modes and four launch curves. | +| Footplant | State601 vtable `82327400`; manager Start `82D704B0`, adjustment `82D70CE0`, update `82D70E40`, publication `82D71040`, nearby-edge refinement `82D6FBB0`. Foot/toe volume setup `82D91330`; postphysics `82D4C6D8`. | +| Handplant contact | Manager `82D61040`; ground query `82D38430`, admission `82D61D88`, position selector `82D63D40`, frame/window/edge clipping `82D640F0..82D65D58`. Uses actual world grind primitives, capped at 40 broadphase candidates. | +| Handplant motion | Launch `82D613B0`; six outgoing queries `82D66318`, selection `82D66948`; arc constructors `82D60770/82D608A0`; COM `82D625C0`, Bezier `82D62408`, orientation `82D62C88`, time warp `82D631A8/82D63280`. Collision position accessor `82D2D988` and landing-normal accessor `82D2D9E8`. | +| 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`; 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: + +| Collection class | Used data | +| --- | --- | +| `CCB95A83C78B4FF9/default` | Four PointNegGraphData8 launch curves for state602. | +| `physics_footplantmanager/default` (`172D553937D8E07C`) | Radial speed curve; contact/deck dimensions; leg lengths; Bezier handles; minimum/maximum plant duration; release and wipeout limits. | +| `55B5940075F78842/default` | Seven PointGraph4 query-window curves; depth and vertical drop. | +| `physics_handplantmanager/default` (`0F38084037CB9175`) | Time warp; orientation/heading curves; hand-radius curve; entry/release/IK timings; admission thresholds; apex and outgoing-velocity parameters. | +| `anim_handplant/default` (`F190522E738EC5CB`) | Out/into/antic thresholds at offsets0/4/8. | + +The antic blend reads actual clip lengths: `INVERT_HANDPLANT_FS_0_ANTIC` is +0.2 seconds and `INVERT_HANDPLANT_FS_0_ANTIC_LATE` is 1/30 second. FootPlantAbsorb +publishes `absorblength` from the physical manager; no host animation timer or +extra jump impulse selects these launches. + +## Validation and boundaries + +Release compilation succeeded. Static inspection checked all 32 operation names +in the seven plant templates against source registrations, the five stock +collection classes and point-graph layouts, both antic clip lengths, and the +`FootJump` attributes in the no-comply/boneless/fastplant clips. This is not a +runtime graph-coverage or gameplay-parity result. + +The game, launcher, recomp and gameplay harnesses were not executed. In-game +entry/release, contacts, stance combinations, wipeouts, visuals and preservation +of skating/off-board/grind behavior remain for the user's validation. + +Queries use the engine's existing synchronous static-world collision and grind +geometry adapters. The outgoing batch is consumed on handplant entry rather +than using TU3's asynchronous job-completion gate and redundant ground-probe +completion dependency. Moving-object coping is not supplied by this adapter. +Scalar vector/trigonometric helpers and quaternion-derived axis-angle rotation +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. + +## Folded-pose report and wall-jump exit (8 September, later run) + +Log plants-20260908-174254-418-287 entered HandPlant at tick581, returned to +PhysicsGround at603 and stopped at605 in Ground's wall-jump callback. That +callback was still an explicit error stub. It now converts the complete retained +launch packet to the existing trajectory selector and executes Launch/Update, +including the wall-jump velocity in both native velocity fields. A focused packet +transfer test passes. No gameplay was executed to validate the result. + +The user's folded-body report remains unresolved. HANDPLANT_POSE now records +COM/orientation, root transform, target versus physical hips/head/hands/toes and +external hand targets at entry and every six ticks. This distinguishes pose/IK +errors from physical tracking errors on the next user-provided reproduction. + +## Handplant apex correction (8 September, folded-body trace) + +The 17:48 run shows the head/hips tracking their targets while feet fall roughly +0.5m behind at tick486. A later attempt at1353 produces a NaN incoming arc from +COM[-6.9291053,3.925457,4.9562364]. These are observations; visual recovery still +requires the user's gameplay validation. + +TU3 Launch82D61458 calls cosine82473930, then82D61464 calls sine824531C8. +Raw byte-permute mask822FBAC0 is00010203000102030001020314151617. Applying it +to the sine/cosine registers and shifting eight bytes produces[sin,cos,0,0]. +82D61520..C4 therefore multiplies UP by radius*cos and side by radius*sin. +The port had these components reversed. With stock radius0.8/angle-0.3 and +copingY3.8850045/Z5.5987973, the corrected apex isY4.649274/Z5.362381; +the old target wasY3.648588/Z6.363067. This placed the old target below and +beyond the lip, consistent with the blocked feet and invalid late-entry arc. +The port now uses the native component order and standalone sin/cos helpers. +Regression tests cover the observed late-entry COM, apex arrival/zero vertical +velocity, and zero-angle symmetry. Pose traces remain enabled for verification. + +## Arm and variation investigation (8 September, 17:54 run) + +User reports the normal body animation improved, with one arm misplaced; +A/B variations still bail. In plants-20260908-175441-712-2596.log, HandPlant +requests reason1 (pose displacement) at ticks2209 and5093, without board +contact or closing velocity. The previous HANDPLANT_POSE samples compare +current targets with the previous physical pose and cannot identify the +within-tick source of that displacement. + +HANDPLANT_SOLVED now captures authored, IK-adjusted and solved arm positions +in world coordinates, arm drive strengths, four limb modes/blends, partial +ragdoll state and per-part pose errors before the handplant bail check. It +samples every six ticks, or every tick when maximum pose error reaches0.15. +This is diagnostic only: neither the arm problem nor variation bails are +claimed fixed. Native82D633B0,82BE3220,82BD9728 and82BEDC10 were inspected; +the selected arm, two0.65m clamps and feet-only external post pass match the +existing port. Need another normal/A/B reproduction to locate the divergence. +The same run later terminates in unsupported IsGrindBluntingBackslash at8059; +that separate grind condition remains unresolved by this diagnostic change. + +## Handplant collision-mask correction (8 September, 18:06 run) + +The requested normal/A/B reproduction is plants-20260908-180656-119-4993.log. +Normal plant316..395 returns to Ground; variations767..788 and1139..1166 +request pose-displacement bail1. Solved traces show the free hand tracking +within0.006m at324, then falling0.57m behind at340. Its IK adjustment is +only0.002m at340. Collision weight drops to0 and the arm switches to soft +drives with local/root strengths0.5/0.0. Both variation attempts repeat this +sequence. No crash was recorded in this run. + +Confirmed source error:82D91298's five volume-pointer writes use offsets +3120,3104,3124,3108,3096; matching counter writes are8156,8140,8160,8144,8132. +The generic EnableBone82BE7190 independently establishes the volume base as +4*773=3092 and counter base as4*2032=8128. Both address sets therefore resolve +to parts7,3,8,4,1 (hands, forearms, head). The port incorrectly disabled +8,4,9,5,2, leaving the hands and head collidable. TU3 dump identity remains +SHA256 f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4. +Evidence: logs/recomp-82d91298.asm and logs/recomp-82be7190.asm. + +Handplant now calls a shared collision-owner method with the corrected +five-part mask and native two-frame countdown. A regression test checks +refresh during the plant, survival across normal driven-state setup, other +bones retaining contact, and automatic re-enabling after exit. Bail +thresholds and authored animation/IK targets are unchanged. The native mask +error is fixed; visual recovery and successful variations need gameplay +confirmation from the user. The game has not been launched by the agent.