From 55cc1eff82ba3d4f84695229d24c5ae7bb237312 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:20:06 +0100 Subject: [PATCH 01/14] Implement TU3 no-comply, footplant and handplant physics --- LaunchPlants.bat | 14 + crates/skate-core/src/air/reckoning/mod.rs | 20 ++ .../skate-core/src/input/trick_intentions.rs | 18 ++ .../src/physics/skeleton_air_frames.rs | 18 ++ .../src/player/input_phase/air_output.rs | 9 + .../src/player/selector/offboard.rs | 4 +- .../skate-core/src/player/wipeout/ground.rs | 13 + crates/skate-core/src/player/wipeout/mod.rs | 2 +- .../src/graph_host/action_conditions.rs | 2 +- .../graph_host/motion_gameplay_conditions.rs | 32 +++ .../src/graph_host/motion_native.rs | 1 + .../skate-game/src/graph_host/motion_nodes.rs | 3 + .../src/graph_host/motion_tricks.rs | 36 +++ crates/skate-game/src/physics.rs | 3 + .../skate-game/src/physics/air_reckoning.rs | 4 + .../skate-game/src/physics/animation_phase.rs | 5 + crates/skate-game/src/physics/boneless.rs | 103 +++++++ crates/skate-game/src/physics/footplant.rs | 20 +- .../src/physics/footplant/ground.rs | 180 ++++++++++++ .../skate-game/src/physics/footplant/math.rs | 29 +- .../src/physics/footplant/settings.rs | 22 ++ .../src/physics/footplant/update.rs | 50 +++- crates/skate-game/src/physics/frame.rs | 4 + crates/skate-game/src/physics/ground_phase.rs | 1 + crates/skate-game/src/physics/handplant.rs | 271 ++++++++++++++++++ .../src/physics/handplant/contact.rs | 242 ++++++++++++++++ crates/skate-game/src/physics/handplant/ik.rs | 90 ++++++ .../src/physics/handplant/rotation.rs | 50 ++++ .../src/physics/handplant/settings.rs | 96 +++++++ .../src/physics/handplant/trajectory.rs | 269 +++++++++++++++++ crates/skate-game/src/physics/input_phase.rs | 4 + .../skate-game/src/physics/input_teleport.rs | 2 + .../src/physics/known_air/runtime.rs | 2 + .../skate-game/src/physics/plant_skeleton.rs | 91 ++++++ .../src/physics/player_state/pre_state.rs | 3 +- .../src/physics/player_state/publication.rs | 11 +- .../src/physics/player_state/transition.rs | 12 +- crates/skate-game/src/physics/skater.rs | 4 + crates/skate-game/src/physics/wipeout.rs | 7 + docs/plants.md | 110 +++++++ 40 files changed, 1829 insertions(+), 28 deletions(-) create mode 100644 LaunchPlants.bat create mode 100644 crates/skate-game/src/physics/boneless.rs create mode 100644 crates/skate-game/src/physics/footplant/ground.rs create mode 100644 crates/skate-game/src/physics/handplant.rs create mode 100644 crates/skate-game/src/physics/handplant/contact.rs create mode 100644 crates/skate-game/src/physics/handplant/ik.rs create mode 100644 crates/skate-game/src/physics/handplant/rotation.rs create mode 100644 crates/skate-game/src/physics/handplant/settings.rs create mode 100644 crates/skate-game/src/physics/handplant/trajectory.rs create mode 100644 crates/skate-game/src/physics/plant_skeleton.rs create mode 100644 docs/plants.md diff --git a/LaunchPlants.bat b/LaunchPlants.bat new file mode 100644 index 00000000..c0c4a206 --- /dev/null +++ b/LaunchPlants.bat @@ -0,0 +1,14 @@ +@echo off +setlocal +cd /d "%~dp0" +set "PLANT_EXE=%~dp0bin\plants\skate3rust.exe" +set "PLANT_ASSETS=C:\Users\Daddy\AppData\Local\Skate3RustEngine\installations\957f4f78db9b4f59959f10dff8ff6a84\assets" +if not exist "%PLANT_EXE%" ( + echo Missing task build: "%PLANT_EXE%" + pause + exit /b 1 +) +"%PLANT_EXE%" --assets "%PLANT_ASSETS%" --test-world %* +set "PLANT_EXIT=%ERRORLEVEL%" +if not "%PLANT_EXIT%"=="0" pause +exit /b %PLANT_EXIT% 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/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/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..ee3bf1a1 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, shared FootPlant/HandPlant postphysics collision checks. +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-game/src/graph_host/action_conditions.rs b/crates/skate-game/src/graph_host/action_conditions.rs index 73d85cb9..8e401980 100644 --- a/crates/skate-game/src/graph_host/action_conditions.rs +++ b/crates/skate-game/src/graph_host/action_conditions.rs @@ -39,7 +39,7 @@ 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, "IsInLocomotion" => Self::InLocomotion, "DisableDismount" => Self::DisableDismount, 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/physics.rs b/crates/skate-game/src/physics.rs index 38f053cf..5c5f837c 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; 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..c265ed3b 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, 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 2c9b97a1..068a26f2 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -104,6 +104,9 @@ pub(super) fn advance( } player_state::pre_state(physics, skater)?; match skater.player_state.current() { + 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 @@ -134,6 +137,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..03b8a855 100644 --- a/crates/skate-game/src/physics/ground_phase.rs +++ b/crates/skate-game/src/physics/ground_phase.rs @@ -164,6 +164,7 @@ pub(crate) fn advance( physics: &mut GamePhysics, skater: &mut SkaterRuntime, ) -> Result { + super::handplant::ground_query(physics,skater); let p = &skater.player_input.processed; let toolkit = skater .player_input diff --git a/crates/skate-game/src/physics/handplant.rs b/crates/skate-game/src/physics/handplant.rs new file mode 100644 index 00000000..05211f96 --- /dev/null +++ b/crates/skate-game/src/physics/handplant.rs @@ -0,0 +1,271 @@ +//! 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, + )?; + 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(()) +} +///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, + ); + //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 disables five upper-body volumes for two frames. + for part in [8, 4, 9, 5, 2] { + skater.skeleton_collision.pending_reenable = true; + skater.skeleton_collision.disable_count[part] = 2; + skater.skeleton_collision.parts[part].enabled = false; + } + 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) +} 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..528a7cb0 --- /dev/null +++ b/crates/skate-game/src/physics/handplant/trajectory.rs @@ -0,0 +1,269 @@ +//! 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 (sin, cos) = skate_core::trigonometry::sin_cos(self.settings.apex_angle); + let apex = madd( + side, + self.settings.apex_radius * cos, + madd(UP, self.settings.apex_radius * sin, c.point), + ); + 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 point = hit.contact_position; //PredictionResults::GetPosition82D2D988. + if self.anchor[1] - point[1] <= 0.5 { + continue; + } + let normal = hit.landing_normal; + 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]) + } +} +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) +} +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/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..df0c7175 100644 --- a/crates/skate-game/src/physics/player_state/pre_state.rs +++ b/crates/skate-game/src/physics/player_state/pre_state.rs @@ -15,7 +15,8 @@ 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::WipeoutGround | PhysicalStateId::Teleporting diff --git a/crates/skate-game/src/physics/player_state/publication.rs b/crates/skate-game/src/physics/player_state/publication.rs index e8185bd1..ecd0a2e5 100644 --- a/crates/skate-game/src/physics/player_state/publication.rs +++ b/crates/skate-game/src/physics/player_state/publication.rs @@ -16,7 +16,8 @@ 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::WipeoutGround | PhysicalStateId::Teleporting @@ -61,6 +62,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> 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 +77,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 +90,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 +128,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> physical.state = CurrentStateFields { category_12: state.category(), state_16: state as u32, + 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..cb362c70 100644 --- a/crates/skate-game/src/physics/player_state/transition.rs +++ b/crates/skate-game/src/physics/player_state/transition.rs @@ -18,6 +18,7 @@ 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::WipeoutGround @@ -34,6 +35,7 @@ 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::WipeoutGround @@ -62,7 +64,8 @@ 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::WipeoutGround | PhysicalStateId::Teleporting @@ -74,6 +77,7 @@ 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::WipeoutGround @@ -157,6 +161,9 @@ 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::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 +179,9 @@ pub(super) fn set( _ => unreachable!("state support checked before publication"), } match requested { + 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/skater.rs b/crates/skate-game/src/physics/skater.rs index 532ac365..7a9568cb 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -45,6 +45,8 @@ pub(crate) struct SkaterRuntime { 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 teleport_state: super::teleport_state::Runtime, @@ -206,6 +208,8 @@ impl SkaterRuntime { 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/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs index f935be96..72b6a21b 100644 --- a/crates/skate-game/src/physics/wipeout.rs +++ b/crates/skate-game/src/physics/wipeout.rs @@ -94,6 +94,13 @@ pub(super) fn check_after_physics( grind_normal: skater.trajectory.selector.grind_normal(), }; match skater.player_state.current() { + PhysicalStateId::FootPlant | PhysicalStateId::HandPlant => { + wipeout::check_plant(&mut skater.wipeout.state, &skater.wipeout.settings, &observations.frame()?); + 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()?; diff --git a/docs/plants.md b/docs/plants.md new file mode 100644 index 00000000..e6c725ea --- /dev/null +++ b/docs/plants.md @@ -0,0 +1,110 @@ +# 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 + +Run `LaunchPlants.bat` in the repository root. It uses +`bin/plants/skate3rust.exe`, the absolute installed asset directory, and +`--test-world`. Additional command-line arguments are forwarded. The default +test course is useful for no-complys and reachable footplant surfaces; handplants +need transition/coping geometry satisfying the native selector. An installed +converted map can be chosen in the map menu or supplied with `--map`. + +The task executable is a separate copy, built with: + +```powershell +$env:CARGO_TARGET_DIR = 'C:/Users/Daddy/Documents/skate3-imported/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`; shared plant wipeout check `82D8FDC0`. 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. From 5f1cf8249d3754e7f64b0c123bbb4f11494ce4af Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:33:03 +0100 Subject: [PATCH 02/14] Preserve plants run output and crash backtraces --- LaunchPlants.bat | 26 ++++++++++++++++++++++++-- docs/plants.md | 6 ++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/LaunchPlants.bat b/LaunchPlants.bat index c0c4a206..5cf6fa01 100644 --- a/LaunchPlants.bat +++ b/LaunchPlants.bat @@ -8,7 +8,29 @@ if not exist "%PLANT_EXE%" ( pause exit /b 1 ) -"%PLANT_EXE%" --assets "%PLANT_ASSETS%" --test-world %* +if not exist "%~dp0logs" mkdir "%~dp0logs" +set "PLANT_STAMP=" +for /f %%T in ('powershell.exe -NoProfile -Command "Get-Date -Format yyyyMMdd-HHmmss-fff"') do set "PLANT_STAMP=%%T" +if not defined PLANT_STAMP set "PLANT_STAMP=run" +set "PLANT_LOG=%~dp0logs\plants-%PLANT_STAMP%-%RANDOM%.log" +> "%PLANT_LOG%" echo Plants run: %DATE% %TIME% +if errorlevel 1 ( + echo Cannot write crash log: "%PLANT_LOG%" + pause + exit /b 1 +) +if exist "%~dp0bin\plants\BUILD.txt" type "%~dp0bin\plants\BUILD.txt" >> "%PLANT_LOG%" +set "RUST_BACKTRACE=full" +set "RUST_LOG_STYLE=never" +set "NO_COLOR=1" +echo Saving game output to "%PLANT_LOG%" +"%PLANT_EXE%" --assets "%PLANT_ASSETS%" --test-world %* >> "%PLANT_LOG%" 2>&1 set "PLANT_EXIT=%ERRORLEVEL%" -if not "%PLANT_EXIT%"=="0" pause +>> "%PLANT_LOG%" echo Exit code: %PLANT_EXIT% +if not "%PLANT_EXIT%"=="0" ( + type "%PLANT_LOG%" + echo. + echo Game exited with code %PLANT_EXIT%. Log saved to "%PLANT_LOG%" + pause +) exit /b %PLANT_EXIT% diff --git a/docs/plants.md b/docs/plants.md index e6c725ea..1609b0f6 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -33,6 +33,12 @@ test course is useful for no-complys and reachable footplant surfaces; handplant need transition/coping geometry satisfying the native selector. An installed converted map can be chosen in the map menu or supplied with `--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 task executable is a separate copy, built with: ```powershell From b27119ae91459b6dc84ec9e1f7b4db7c012efede Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:36:19 +0100 Subject: [PATCH 03/14] Fix RMB exit from obsolete world-grab guard --- crates/skate-game/src/physics/ground_phase.rs | 1 - .../src/physics/ground_runtime/mod.rs | 1 - .../src/physics/ground_runtime/state.rs | 2 - .../src/physics/ground_runtime/update.rs | 14 ++--- .../src/physics/ground_runtime/world_grab.rs | 59 ------------------- docs/plants.md | 8 +++ 6 files changed, 14 insertions(+), 71 deletions(-) delete mode 100644 crates/skate-game/src/physics/ground_runtime/world_grab.rs diff --git a/crates/skate-game/src/physics/ground_phase.rs b/crates/skate-game/src/physics/ground_phase.rs index 03b8a855..047440b4 100644 --- a/crates/skate-game/src/physics/ground_phase.rs +++ b/crates/skate-game/src/physics/ground_phase.rs @@ -268,7 +268,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/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/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/docs/plants.md b/docs/plants.md index 1609b0f6..b37bd0a7 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -39,6 +39,14 @@ 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 task executable is a separate copy, built with: ```powershell From a9f8c8779cbe16c3c28b4812c78f29505f66a3e9 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:43:45 +0100 Subject: [PATCH 04/14] Capture first invalid body around shared physical solve --- crates/skate-game/src/physics.rs | 3 +- crates/skate-game/src/physics/solve.rs | 8 +++++ .../src/physics/solve/diagnostics.rs | 35 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 crates/skate-game/src/physics/solve/diagnostics.rs diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 5c5f837c..1dbd4620 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -324,7 +324,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/solve.rs b/crates/skate-game/src/physics/solve.rs index a6932a02..776c55d7 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,6 +15,8 @@ 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")?; let board_volumes = if skater.offboard.board_policy.volumes_enabled { colliders::world_volumes(&physics.board, &physics.settings) } else { @@ -86,6 +89,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(()) +} From 67f656bfd7ad88cdc00f8507b125e5e16fb01a73 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:52:53 +0100 Subject: [PATCH 05/14] Filter world-disabled skater volumes without dropping assembly contacts --- .../src/physics/skeleton_colliders.rs | 23 ++++++++++++++++++- crates/skate-game/src/physics/solve.rs | 6 +++-- docs/plants.md | 8 +++++++ 3 files changed, 34 insertions(+), 3 deletions(-) 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 776c55d7..3c47cd7e 100644 --- a/crates/skate-game/src/physics/solve.rs +++ b/crates/skate-game/src/physics/solve.rs @@ -23,7 +23,7 @@ pub(super) fn advance( 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 @@ -37,8 +37,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, )); diff --git a/docs/plants.md b/docs/plants.md index b37bd0a7..354c70a9 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -47,6 +47,14 @@ 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; the separately logged +off-board world-object query and non-finite-pose failures remain under investigation. + The task executable is a separate copy, built with: ```powershell From 329101d432a78bdd7755efbfb70b8afd196c95a7 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:00:26 +0100 Subject: [PATCH 06/14] Capture source targets for invalid off-board follower velocity --- crates/skate-game/src/physics/solve.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs index 3c47cd7e..7defdd42 100644 --- a/crates/skate-game/src/physics/solve.rs +++ b/crates/skate-game/src/physics/solve.rs @@ -16,7 +16,14 @@ pub(super) fn advance( truck_targets: [f32; 2], ) -> Result<(), String> { let before = diagnostics::snapshot(physics, skater); - diagnostics::validate(&before, "before shared solve")?; + 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 { From 3c1c2ace1e35aaa16ccc3f0521a36c936c1d6fa4 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:04:47 +0100 Subject: [PATCH 07/14] Handle empty off-board object queries without exiting --- .../src/physics/offboard/ground_state.rs | 30 +++++++++---------- docs/plants.md | 12 ++++++-- 2 files changed, 25 insertions(+), 17 deletions(-) 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/docs/plants.md b/docs/plants.md index 354c70a9..135b4917 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -52,8 +52,16 @@ 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; the separately logged -off-board world-object query and non-finite-pose failures remain under investigation. +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 task executable is a separate copy, built with: From f123316e8c16505df78637cee02ff5d490a5d2f3 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:14:01 +0100 Subject: [PATCH 08/14] Keep handplant landing arcs finite for nearby ledge queries --- .../src/physics/handplant/trajectory.rs | 15 +++++++++++++-- docs/plants.md | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/skate-game/src/physics/handplant/trajectory.rs b/crates/skate-game/src/physics/handplant/trajectory.rs index 528a7cb0..1e7c983a 100644 --- a/crates/skate-game/src/physics/handplant/trajectory.rs +++ b/crates/skate-game/src/physics/handplant/trajectory.rs @@ -112,11 +112,22 @@ impl Handplant { if hit.contact_time < 0.0 { continue; } - let point = hit.contact_position; //PredictionResults::GetPosition82D2D988. - if self.anchor[1] - point[1] <= 0.5 { + 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; diff --git a/docs/plants.md b/docs/plants.md index 135b4917..859687a4 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -63,6 +63,15 @@ 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 task executable is a separate copy, built with: ```powershell From ab8f889603924d2e043a4c4b5d172074f06c5d5f Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:19:44 +0100 Subject: [PATCH 09/14] Wire action drop-in condition and trace handplant entry decisions --- crates/skate-game/src/graph_host/action.rs | 2 ++ crates/skate-game/src/graph_host/action_conditions.rs | 4 ++++ crates/skate-game/src/physics/animation_phase.rs | 1 + crates/skate-game/src/physics/frame.rs | 8 ++++++++ crates/skate-game/src/physics/handplant.rs | 8 ++++++++ docs/plants.md | 8 ++++++++ 6 files changed, 31 insertions(+) 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 8e401980..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), @@ -41,6 +42,7 @@ impl ActionCondition { }, "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/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs index c265ed3b..1bbf415d 100644 --- a/crates/skate-game/src/physics/animation_phase.rs +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -208,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/frame.rs b/crates/skate-game/src/physics/frame.rs index 068a26f2..103974cf 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -94,6 +94,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 { diff --git a/crates/skate-game/src/physics/handplant.rs b/crates/skate-game/src/physics/handplant.rs index 05211f96..abcb81ab 100644 --- a/crates/skate-game/src/physics/handplant.rs +++ b/crates/skate-game/src/physics/handplant.rs @@ -148,6 +148,8 @@ pub(super) fn ground_update( 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, @@ -208,6 +210,12 @@ pub(super) fn ground_query(physics: &GamePhysics, skater: &mut SkaterRuntime) { 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; diff --git a/docs/plants.md b/docs/plants.md index 859687a4..1c53a995 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -72,6 +72,14 @@ 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 From 0ddc174a5168e1b1fc3598312f53901519eec559 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:42:00 +0100 Subject: [PATCH 10/14] Correct handplant bail dispatch and implement RevertGround physics --- crates/skate-core/src/physics/board_ground.rs | 2 +- .../skate-core/src/player/wipeout/ground.rs | 2 +- crates/skate-core/src/player/wipeout/tests.rs | 20 ++ crates/skate-game/src/graphics_menu.rs | 1 + crates/skate-game/src/physics.rs | 1 + crates/skate-game/src/physics/frame.rs | 1 + .../src/physics/ground_runtime/pumping.rs | 40 +++ .../src/physics/player_state/pre_state.rs | 1 + .../src/physics/player_state/publication.rs | 3 + .../src/physics/player_state/transition.rs | 6 + crates/skate-game/src/physics/revert_state.rs | 266 ++++++++++++++++++ crates/skate-game/src/physics/skater.rs | 2 + crates/skate-game/src/physics/wipeout.rs | 20 +- crates/skate-game/src/skate_world.rs | 2 + docs/plants.md | 24 +- 15 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 crates/skate-game/src/physics/revert_state.rs 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/player/wipeout/ground.rs b/crates/skate-core/src/player/wipeout/ground.rs index ee3bf1a1..e4c41400 100644 --- a/crates/skate-core/src/player/wipeout/ground.rs +++ b/crates/skate-core/src/player/wipeout/ground.rs @@ -127,7 +127,7 @@ pub fn check_animation(state: &mut Requests, s: &Settings, mode: &Mode, f: &Fram } } -///82D8FDC0, shared FootPlant/HandPlant postphysics collision checks. +///82D8FDC0, FootPlant postphysics checks. HandPlant calls check_air(false). pub fn check_plant(state: &mut Requests, s: &Settings, f: &Frame) { state.mode = 3; if f.maximum_pose_error > s.ground.max_squash { 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/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index a3d77f99..94948454 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -461,6 +461,7 @@ mod tests { .insert_resource(images) .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), + maps: Vec::new(), selected_map: 0, difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), }) .add_systems(Update, apply); diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 1dbd4620..cdda0218 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -39,6 +39,7 @@ mod grind; mod ground_phase; mod ground_animation; mod slide_state; +mod revert_state; mod ground_exit; mod ground_runtime; mod input_phase; diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 103974cf..637778ef 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -112,6 +112,7 @@ pub(super) fn advance( } player_state::pre_state(physics, skater)?; match skater.player_state.current() { + skate_core::player::state::PhysicalStateId::RevertGround => super::revert_state::update(physics,skater)?, skate_core::player::state::PhysicalStateId::HandPlant => super::handplant::update(physics,skater)?, skate_core::player::state::PhysicalStateId::FootPlant => super::footplant::ground::update(physics, skater)?, skate_core::player::state::PhysicalStateId::Boneless => super::boneless::update(physics, skater)?, 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/player_state/pre_state.rs b/crates/skate-game/src/physics/player_state/pre_state.rs index df0c7175..76c58600 100644 --- a/crates/skate-game/src/physics/player_state/pre_state.rs +++ b/crates/skate-game/src/physics/player_state/pre_state.rs @@ -18,6 +18,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir diff --git a/crates/skate-game/src/physics/player_state/publication.rs b/crates/skate-game/src/physics/player_state/publication.rs index ecd0a2e5..9a0f8a36 100644 --- a/crates/skate-game/src/physics/player_state/publication.rs +++ b/crates/skate-game/src/physics/player_state/publication.rs @@ -19,6 +19,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -58,6 +59,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> // State63 is set only by LandingOnDeckManager::Fill82D4E0E8..E0F4 // when byte246 && time240<.02. Ordinary Ground never invokes that Fill, // so it retains the template zero here; State65 carries Ground requests. + set(66, state == PhysicalStateId::RevertGround && skater.revert_state.active); set(65, skater.wipeout.requests_wipeout(p)); set(78, skater.wipeout.requests_runout(p)); set(71, player.flags_1296 & (1 << 24) != 0); @@ -128,6 +130,7 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> physical.state = CurrentStateFields { category_12: state.category(), state_16: state as u32, + flag_66: u8::from(state == PhysicalStateId::RevertGround && skater.revert_state.active), flag_74: u8::from(state==PhysicalStateId::HandPlant && skater.handplant.continuation), //ProcessOutput82DB7104..7128: selector57 requests State69 here. //The next input/selection enters702; never reset inside selection. diff --git a/crates/skate-game/src/physics/player_state/transition.rs b/crates/skate-game/src/physics/player_state/transition.rs index cb362c70..63eb1298 100644 --- a/crates/skate-game/src/physics/player_state/transition.rs +++ b/crates/skate-game/src/physics/player_state/transition.rs @@ -21,6 +21,7 @@ impl PhysicalStateCalls for Calls { | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -38,6 +39,7 @@ impl PhysicalStateCalls for Calls { | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -67,6 +69,7 @@ pub(super) fn set( | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -80,6 +83,7 @@ pub(super) fn set( | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::GroundAnimation | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround | PhysicalStateId::WipeoutGround | PhysicalStateId::Teleporting | PhysicalStateId::BipedAir @@ -161,6 +165,7 @@ pub(super) fn set( //Native82DB8540 publishes Processed state/history BEFORE old Exit. The //same retained objects then receive Exit followed by the new Enter. match current { + PhysicalStateId::RevertGround => {}, //Exit82B61BB8 PhysicalStateId::HandPlant => skater.handplant.reset(), PhysicalStateId::FootPlant => skater.footplant.reset(), //Exit82D4C5A8 PhysicalStateId::Boneless => {}, //empty82D4C9B4 @@ -179,6 +184,7 @@ pub(super) fn set( _ => unreachable!("state support checked before publication"), } match requested { + PhysicalStateId::RevertGround => super::super::revert_state::enter(physics, skater), PhysicalStateId::HandPlant => super::super::handplant::enter(physics, skater), PhysicalStateId::FootPlant => super::super::footplant::ground::enter(physics, skater), PhysicalStateId::Boneless => super::super::boneless::enter(physics, skater), 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 7a9568cb..955d47ac 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -42,6 +42,7 @@ pub(crate) struct SkaterRuntime { pub known_air: super::known_air::KnownAir, pub ground_animation: super::ground_animation::GroundAnimationRuntime, pub ground_animation_settings: super::ground_animation::GroundAnimationSettings, + pub revert_state: super::revert_state::RevertState, pub slide_state: super::slide_state::SlideState, pub trajectory: super::air_trajectory::AirTrajectoryRuntime, pub footplant: super::footplant::Footplant, @@ -205,6 +206,7 @@ impl SkaterRuntime { known_air: super::known_air::KnownAir::load(&data)?, ground_animation: Default::default(), ground_animation_settings: super::ground_animation::GroundAnimationSettings::load(&data)?, + revert_state: super::revert_state::RevertState::load(&data)?, slide_state: super::slide_state::SlideState::load(&data)?, trajectory: super::air_trajectory::AirTrajectoryRuntime::load(&data)?, footplant: super::footplant::Footplant::load(&data)?, diff --git a/crates/skate-game/src/physics/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs index 72b6a21b..54c766c4 100644 --- a/crates/skate-game/src/physics/wipeout.rs +++ b/crates/skate-game/src/physics/wipeout.rs @@ -95,7 +95,23 @@ pub(super) fn check_after_physics( }; match skater.player_state.current() { PhysicalStateId::FootPlant | PhysicalStateId::HandPlant => { - wipeout::check_plant(&mut skater.wipeout.state, &skater.wipeout.settings, &observations.frame()?); + let frame = observations.frame()?; + if skater.player_state.current() == PhysicalStateId::HandPlant { + //HandPlant vtable823273CC slot40 ->82D4C530 ->82D90358(false). + //FootPlant's82D4C6D8 alone uses82D8FDC0. Its low trick-impact + //thresholds must not replace HandPlant's normal air checks. + skater.wipeout.check_air(&observations, false)?; + } else { + wipeout::check_plant(&mut skater.wipeout.state, &skater.wipeout.settings, &frame); + } + if skater.wipeout.state.count != 0 { + let reasons: Vec<_> = skater.wipeout.state.reasons.iter().enumerate() + .filter_map(|(i, &active)| active.then_some(i)).collect(); + bevy::log::info!("PLANT_BAIL tick={} state={:?} reasons={reasons:?} pose_error={:?} max_pose_error={} regions={:?} closing={:?} board_contact={} world_to_animation={:?}", + physics.ticks, skater.player_state.current(), frame.pose_error, + frame.maximum_pose_error, frame.regions_force, frame.closing_velocity, + frame.board_contact, frame.world_to_animation); + } if skater.player_state.current() == PhysicalStateId::FootPlant { super::footplant::ground::post_physics(skater); } @@ -127,7 +143,7 @@ pub(super) fn check_after_physics( super::offboard::air_diagnostics::record(skater); Ok(()) } - PhysicalStateId::PhysicsGround | PhysicalStateId::SlideGround => { + PhysicalStateId::PhysicsGround | PhysicalStateId::SlideGround | PhysicalStateId::RevertGround => { skater.wipeout.check_ground(&observations) } PhysicalStateId::GroundAnimation => { diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index ee1903d2..b4d98cb7 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -853,12 +853,14 @@ mod tests { let mut queue = bevy::ecs::world::CommandQueue::default(); let mut meshes = Assets::::default(); let mut materials = Assets::::default(); + let mut retail_materials = Assets::::default(); let mut images = Assets::::default(); spawn( &map, &mut Commands::new(&mut queue, &world), &mut meshes, &mut materials, + &mut retail_materials, &mut images, ); queue.apply(&mut world); diff --git a/docs/plants.md b/docs/plants.md index 1c53a995..54a5224d 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -117,7 +117,7 @@ operands take precedence over unreliable decompiler output and community names. | Handplant skeleton | State600 vtable `823273CC`; entry/update `82D4C350/82D4C3D8`; reckoning `82D8E1C0`; hand IK `82D633B0`, target clamp `82BD9728/82BD97D0`, upper-body volume suppression `82D91298`. | | Shared skeleton | Anchored-foot path `82BDEC48`, COM path `82BDF090`, root positioning `82BDEEB8/82BE0E80`. Uses the existing completed animation, physical skeleton, board and four-limb IK owners. | | Graph timing | `IsFootPlanting` `82BBD888`; preparation `82BBD7F0`; FootPlantAbsorb Update `82BBE4E0`; handplant phase/new-position `82BBDA98/82BBDB98`; antic Begin `82BBE5D0`; handplant scoring Update `82BBF758`. | -| Lifecycle | Footplant release `82D8B970` reads big-endian byte2480 bit0, hence full-word bit24. Common handplant publication `82DB7130..82DB7190`; shared plant wipeout check `82D8FDC0`. Teleport/reset paths clear active plant state. | +| Lifecycle | Footplant release `82D8B970` reads big-endian byte2480 bit0, hence full-word bit24. Common handplant publication `82DB7130..82DB7190`; FootPlant wipeout check `82D8FDC0`; HandPlant post `82D4C530` calls Air `82D90358(false)`. Teleport/reset paths clear active plant state. | Settings are loaded from the installation, using the schema's cached layout: @@ -155,3 +155,25 @@ reuse existing engine math; bit-exact Xenon/VMX execution is not claimed. Handplant scoring updates the existing internal score packet, whose presentation remains outside this change. Stock assets and private research/build files are excluded from the commit. + +## Handplant bail and delayed revert crash (8 September) + +The supplied run entered HandPlant at tick745 and WipeoutGround at746, then +stopped at tick1318 on the missing PhysicsGround -> RevertGround adapter. +HandPlant incorrectly used FootPlant collision checks with unconditional trick +impact limits (stock XZ0.1/Y0.6). It now dispatches the native Air(false) checks, +including their contact, difficulty, cooldown and actual danger-state gates. +The original bail reason was not recorded, so the causal gameplay result still +requires a user retest. PLANT_BAIL now records reason indices and collision/pose +observations when either plant requests a bail. + +RevertGround102 now owns Enter82D43488, Update82D43518, empty Exit, Ground +Post82D4C070 and Fill82D43B10. It loads the five fields from stock class +018E8A2E5028AB3F, captures approach velocity after the authored delay, applies +the directed rotation-speed curve, manual/anti-flip corrections and pumping, +and publishes active State66 for the existing selector's normal exit. +No gameplay execution was performed during this repair. + +Validation for this repair: six core wipeout tests and two revert numerical +tests passed. Two pre-existing game test fixtures were updated for their current +map-menu and render-material API signatures so these tests could compile. From a5e2318a32b160eb6e285b98a5d3f0ed074f07a9 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:47:44 +0100 Subject: [PATCH 11/14] Connect Ground wall-jump trajectory and capture handplant pose diagnostics --- crates/skate-game/src/physics/ground_phase.rs | 8 +++- .../src/physics/ground_runtime/launch.rs | 38 +++++++++++++++++++ crates/skate-game/src/physics/handplant.rs | 16 +++++++- docs/plants.md | 14 +++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/skate-game/src/physics/ground_phase.rs b/crates/skate-game/src/physics/ground_phase.rs index 047440b4..5ab412ea 100644 --- a/crates/skate-game/src/physics/ground_phase.rs +++ b/crates/skate-game/src/physics/ground_phase.rs @@ -165,6 +165,7 @@ pub(crate) fn advance( 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 @@ -197,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, 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/handplant.rs b/crates/skate-game/src/physics/handplant.rs index abcb81ab..ac2b3b11 100644 --- a/crates/skate-game/src/physics/handplant.rs +++ b/crates/skate-game/src/physics/handplant.rs @@ -275,5 +275,19 @@ pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> R heading, ); ik::update(skater, false); - plant_skeleton::advance(physics, skater, com, None) + 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/docs/plants.md b/docs/plants.md index 54a5224d..75172e8d 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -177,3 +177,17 @@ 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. From 920c765078789b9b055f99c05d962c93ea78f825 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:53:08 +0100 Subject: [PATCH 12/14] Correct handplant apex sine and cosine component order --- .../src/physics/handplant/trajectory.rs | 44 ++++++++++++++++--- docs/plants.md | 19 ++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/skate-game/src/physics/handplant/trajectory.rs b/crates/skate-game/src/physics/handplant/trajectory.rs index 1e7c983a..961fbebf 100644 --- a/crates/skate-game/src/physics/handplant/trajectory.rs +++ b/crates/skate-game/src/physics/handplant/trajectory.rs @@ -17,12 +17,7 @@ impl Handplant { side = scale(side, -1.0); } self.direction = scale(side, -1.0); - let (sin, cos) = skate_core::trigonometry::sin_cos(self.settings.apex_angle); - let apex = madd( - side, - self.settings.apex_radius * cos, - madd(UP, self.settings.apex_radius * sin, c.point), - ); + 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; @@ -246,10 +241,47 @@ impl Handplant { (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) diff --git a/docs/plants.md b/docs/plants.md index 75172e8d..126df3ea 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -191,3 +191,22 @@ 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. From dbefea66e1aac0551fd4f8c62ca8216b3424f437 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 18:05:02 +0100 Subject: [PATCH 13/14] Capture solved handplant arm poses and variation bail diagnostics --- crates/skate-game/src/physics/handplant.rs | 21 +++++++++++++++++++++ crates/skate-game/src/physics/wipeout.rs | 1 + docs/plants.md | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/crates/skate-game/src/physics/handplant.rs b/crates/skate-game/src/physics/handplant.rs index ac2b3b11..d09f2208 100644 --- a/crates/skate-game/src/physics/handplant.rs +++ b/crates/skate-game/src/physics/handplant.rs @@ -166,6 +166,27 @@ pub(super) fn ground_update( } 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; diff --git a/crates/skate-game/src/physics/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs index 54c766c4..0ae44ab3 100644 --- a/crates/skate-game/src/physics/wipeout.rs +++ b/crates/skate-game/src/physics/wipeout.rs @@ -97,6 +97,7 @@ pub(super) fn check_after_physics( 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. diff --git a/docs/plants.md b/docs/plants.md index 126df3ea..da440fe0 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -210,3 +210,23 @@ 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. From c74aa44c88c09644addc7e280842524ee00e4b39 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 18:14:07 +0100 Subject: [PATCH 14/14] Correct handplant collision bone indices to prevent arm drive collapse --- .../physics/skeleton_body/collision_mode.rs | 11 ++++++++ .../skeleton_body/tests/collision_feedback.rs | 27 +++++++++++++++++++ crates/skate-game/src/physics/handplant.rs | 8 ++---- docs/plants.md | 27 +++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) 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-game/src/physics/handplant.rs b/crates/skate-game/src/physics/handplant.rs index d09f2208..17d37045 100644 --- a/crates/skate-game/src/physics/handplant.rs +++ b/crates/skate-game/src/physics/handplant.rs @@ -269,12 +269,8 @@ pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re Ok(()) } pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { - //82D4C3D8 ->82D91298 disables five upper-body volumes for two frames. - for part in [8, 4, 9, 5, 2] { - skater.skeleton_collision.pending_reenable = true; - skater.skeleton_collision.disable_count[part] = 2; - skater.skeleton_collision.parts[part].enabled = false; - } + //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(); diff --git a/docs/plants.md b/docs/plants.md index da440fe0..a05617d2 100644 --- a/docs/plants.md +++ b/docs/plants.md @@ -230,3 +230,30 @@ 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.